Build a Projectile Motion Simulator

Simulate and visualize projectile motion trajectories using Python.

Introduction

30 Second Summary

You have probably watched a basketball arc through the air or wondered how far a cannonball flies. The path any launched object follows is called projectile motion, and it is governed by a handful of equations you can code yourself.

In this project, you will build a Python program that simulates projectile motion from scratch. You will turn trigonometry and kinematics equations into working code, then visualize the results as trajectory plots using matplotlib.

What You'll Build

A Python simulator that takes a launch angle, initial velocity, and gravity as inputs, then calculates and plots the full trajectory of a projectile as an annotated curve.

By the end of this project, you'll have:

  • A physics engine that decomposes velocity into components using trigonometry and computes flight time, max height, and range from kinematics equations.
  • An annotated trajectory plot showing the parabolic arc of a projectile with labeled axes, max height, and landing point.
  • A multi-angle comparison figure that plots trajectories at different launch angles on the same chart, confirming the 45-degree optimum.
  • A secret mission where you add air resistance using Euler's method and compare real-world drag to the ideal trajectory.

Are there any prerequisites?

You should have basic Python familiarity with variables, functions, and loops. No prior physics or math knowledge beyond basic algebra is required. This project will teach you the trigonometry and kinematics concepts as you go.

Set Up Python and Install Libraries

Before you can simulate anything, you need a working Python environment with the right scientific libraries installed.

In this step, you will verify Python is ready, create an isolated project folder, and install NumPy and matplotlib. These two libraries handle all the math and plotting your simulator needs.

In this step, get ready to:
  • Verify Python and pip are installed on your machine.
  • Create a project folder with an isolated virtual environment.
  • Install NumPy and matplotlib inside the virtual environment.
Verify Python is installed

Your simulator relies on Python 3. Let's confirm it is installed and accessible from your terminal.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

I see a version number

You should see something like Python 3.14.4 (or any 3.x version). Python is ready to go.

Command not found

Python is not installed yet. Follow these steps to install it.

  • Go to python.org/downloads in your browser.
  • Click the large Download Python 3.14.4 button (or the latest 3.x version shown).
  • Run the downloaded installer.
  • On Windows, check the box labeled Add python.exe to PATH before clicking Install Now. This is critical. Without it, your terminal will not find the python command.
  • On macOS, follow the installer prompts and accept the defaults.
  • Close and reopen your terminal, then run the version check command again.
  • Verify that pip (Python's package manager) is available by running:

macOS

python3 -m pip --version

Windows

python -m pip --version

You should see a version number and a file path printed. This confirms pip is ready to install packages.

What is pip?

pip is Python's built-in package manager. It downloads and installs libraries from the Python Package Index (PyPI). You will use it to install NumPy and matplotlib in a moment.

pip not found?

If you see an error, pip may not have been included with your Python installation. Try running python3 -m ensurepip --upgrade (macOS) or python -m ensurepip --upgrade (Windows) to bootstrap it.

Help me fix my pip installation.

Create a project folder and virtual environment

A virtual environment is an isolated Python workspace. Libraries you install inside it stay separate from the rest of your system, so different projects never clash.

  • Move to your Desktop by running this command:

macOS

cd ~/Desktop

Windows

cd ~\Desktop
  • Create your project folder and move into it by running these commands:

macOS

mkdir projectile-simulator
cd projectile-simulator

Windows

mkdir projectile-simulator
cd projectile-simulator
  • Create a virtual environment inside this folder by running:

macOS

python3 -m venv .venv

Windows

python -m venv .venv
  • Activate the virtual environment by running:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\Activate.ps1

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What does (.venv) mean?

The (.venv) prefix tells you that any pip installs will go into this project's .venv folder instead of your system-wide Python. When you are done working, you can type deactivate to leave the virtual environment.

Don't see (.venv) in your prompt?

Make sure you ran the activate command from inside the projectile-simulator folder. Run ls (macOS) or dir (Windows) and confirm you see a .venv folder listed.

On Windows, PowerShell may block the script. If you see a permissions error, run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser first, then try the activate command again.

Help me activate my virtual environment.

Install NumPy and matplotlib

NumPy handles the math (trigonometry, arrays, constants like pi). matplotlib handles the plotting. Together, they are the standard toolkit for scientific computing in Python.

  • Install both libraries by running this command:

macOS

python3 -m pip install numpy==2.4.4 matplotlib==3.10.8

Windows

python -m pip install numpy==2.4.4 matplotlib==3.10.8

This downloads and installs both packages into your virtual environment. You will see progress bars and a Successfully installed message when it finishes.

Why pin version numbers?

The ==2.4.4 and ==3.10.8 syntax locks each library to a specific version. This guarantees your code behaves the same way as this project expects, even if newer versions are released later.

  • Verify both libraries installed correctly by running:

macOS

python3 -c "import numpy; import matplotlib; print(numpy.__version__, matplotlib.__version__)"

Windows

python -c "import numpy; import matplotlib; print(numpy.__version__, matplotlib.__version__)"

You should see 2.4.4 3.10.8 printed. This confirms both libraries are installed and importable.

Seeing an ImportError?

Make sure your virtual environment is still active. Look for (.venv) in your terminal prompt. If it is missing, re-run the activate command from the previous substep.

If the install command itself failed, check that you have an internet connection and try running it again.

Help me fix my library installation.

Your environment is ready. Next up, you will build the physics engine that powers your simulator.

Build the Physics Engine

Your Python environment is ready, and you have numpy and matplotlib installed.

Now it's time to translate real physics equations into Python code. You'll start by decomposing velocity using trigonometry, then compute key flight values like max height and range, and finally generate a full trajectory of positions over time.

In this step, get ready to:
  • Write a function that decomposes velocity into horizontal and vertical components using trigonometry.
  • Calculate flight time, max height, and range using kinematics equations.
  • Generate the full trajectory as arrays of x and y positions over time.
Decompose velocity into components

When you launch a projectile at an angle, its velocity has two parts: a horizontal component that carries it forward, and a vertical component that carries it upward against gravity. Trigonometry is how you split a single velocity into these two directions.

  • Create a file called simulator.py inside your projectile-simulator folder by running this command:
touch simulator.py
  • Confirm the file exists by running:
ls

You should see simulator.py listed in the output.

  • Open simulator.py in your text editor.
  • Add the following code to import numpy and define the velocity decomposition function:
import numpy as np

def decompose_velocity(angle_deg, v0):
    """Split initial velocity into horizontal and vertical components."""
    # Convert the launch angle from degrees to radians
    angle_rad = np.radians(angle_deg)

    # Horizontal component: how fast the projectile moves forward
    vx = v0 * np.cos(angle_rad)

    # Vertical component: how fast the projectile moves upward
    vy = v0 * np.sin(angle_rad)

    return vx, vy

# Test the function with a 45-degree launch at 50 m/s
vx, vy = decompose_velocity(45, 50)
print(f"Horizontal velocity (vx): {vx:.2f} m/s")
print(f"Vertical velocity (vy): {vy:.2f} m/s")

What does this code do?

  • np.radians(angle_deg) converts degrees (like 45) into radians, because numpy's trig functions expect radians.
  • np.cos(angle_rad) gives the horizontal fraction of the velocity. At 45 degrees, cos(45) is about 0.707.
  • np.sin(angle_rad) gives the vertical fraction. At 45 degrees, sin(45) is also about 0.707, so both components are equal.
  • Save simulator.py.
  • Run the script to see your velocity components by running this command:
python3 simulator.py

You should see both vx and vy printed as approximately 35.36 m/s.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
  • If you don't see it, activate the environment again with source .venv/bin/activate (macOS/Linux) or .venv\Scripts\activate (Windows).

Help me fix a ModuleNotFoundError when running simulator.py

Why do numpy's trig functions use radians?

Radians are the standard unit for angles in mathematics and computing. One full circle is 2π radians (about 6.28). Degrees are more intuitive for humans (360 per circle), which is why np.radians() exists as a convenient converter.

Calculate flight time, max height, and range

With the velocity components ready, you can now compute three key values that describe the entire flight. These come from standard kinematics equations that assume flat ground, launch from ground level, and no air resistance.

  • Add this function below your decompose_velocity function in simulator.py:
def compute_flight_stats(vx, vy, g=9.81):
    """Calculate flight time, max height, and horizontal range."""
    # Total flight time: how long until the projectile returns to ground level
    T = 2 * vy / g

    # Maximum height: the peak of the arc
    H = vy**2 / (2 * g)

    # Horizontal range: total distance traveled forward
    R = vx * T

    return T, H, R

What do these equations mean?

  • T = 2 * vy / g calculates total flight time. The projectile takes vy / g seconds to reach the top, then the same time to fall back down.
  • H = vy² / (2g) is the maximum height. Gravity decelerates the vertical velocity to zero at the peak.
  • R = vx * T is the horizontal range. The horizontal velocity stays constant (no air resistance), so distance is just speed times time.
  • Now update the test code at the bottom of simulator.py to call this new function.
  • Find this line:
print(f"Vertical velocity (vy): {vy:.2f} m/s")
  • Add the following lines directly below it:
# Compute and display the key flight statistics
T, H, R = compute_flight_stats(vx, vy)
print(f"Flight time: {T:.2f} s")
print(f"Max height: {H:.2f} m")
print(f"Range: {R:.2f} m")
  • Save simulator.py and run the script again:
python3 simulator.py

You should see the velocity components from before, plus three new lines: flight time of about 7.21 s, max height of about 63.71 m, and range of about 254.84 m.

Seeing unexpected values?

  • Double-check that your test code uses an angle of 45 and an initial velocity of 50. Different inputs produce different results.
  • Make sure the gravity default is 9.81 in the function signature, not 9.8 or 10.

Help me debug my flight stats output

Generate the full trajectory

You know the flight time, but a plot needs hundreds of data points to draw a smooth curve. You'll create an array of time values from 0 to T, then compute the projectile's x and y position at each moment.

  • Add this function below compute_flight_stats in simulator.py:
def generate_trajectory(vx, vy, T, g=9.81, num_points=500):
    """Compute x and y positions at evenly spaced time steps."""
    # Create 500 evenly spaced time values from launch to landing
    t = np.linspace(0, T, num=num_points)

    # Horizontal position: constant velocity, so x grows linearly
    x = vx * t

    # Vertical position: starts going up, gravity pulls it into a parabola
    y = vy * t - 0.5 * g * t**2

    return t, x, y

What does this code do?

  • np.linspace(0, T, num=500) creates an array of 500 evenly spaced time values between 0 and T. More points means a smoother curve.
  • x = vx * t computes horizontal position at every time step. Since there's no horizontal force, the projectile moves at a constant speed.
  • y = vy * t - 0.5 * g * t² computes vertical position. The - 0.5 * g * t² term is gravity pulling the projectile down, creating the parabolic arc.
  • Now replace the test code at the bottom of your file (everything below the function definitions) with this updated version that tests all three functions together:
# --- Test all three functions ---
angle = 45
v0 = 50

# Step 1: Decompose velocity
vx, vy = decompose_velocity(angle, v0)
print(f"Horizontal velocity (vx): {vx:.2f} m/s")
print(f"Vertical velocity (vy): {vy:.2f} m/s")

# Step 2: Compute flight stats
T, H, R = compute_flight_stats(vx, vy)
print(f"Flight time: {T:.2f} s")
print(f"Max height: {H:.2f} m")
print(f"Range: {R:.2f} m")

# Step 3: Generate trajectory arrays
t, x, y = generate_trajectory(vx, vy, T)
print(f"Trajectory computed: {len(x)} data points")
  • Save simulator.py and run it:
python3 simulator.py

You should see all the previous output, plus a new line confirming Trajectory computed: 500 data points.

Seeing a NameError?

  • Make sure all three functions (decompose_velocity, compute_flight_stats, generate_trajectory) are defined above the test code, not below it.
  • Check that you didn't accidentally delete the import numpy as np line at the top of the file.

Help me fix a NameError in my simulator

Awesome, I've got everything!

Great. Double-check you've saved simulator.py before moving on.

I'd like to double check the full code

Here's the complete simulator.py file so far. Compare it against yours to make sure everything matches.

import numpy as np

def decompose_velocity(angle_deg, v0):
    """Split initial velocity into horizontal and vertical components."""
    # Convert the launch angle from degrees to radians
    angle_rad = np.radians(angle_deg)

    # Horizontal component: how fast the projectile moves forward
    vx = v0 * np.cos(angle_rad)

    # Vertical component: how fast the projectile moves upward
    vy = v0 * np.sin(angle_rad)

    return vx, vy

def compute_flight_stats(vx, vy, g=9.81):
    """Calculate flight time, max height, and horizontal range."""
    # Total flight time: how long until the projectile returns to ground level
    T = 2 * vy / g

    # Maximum height: the peak of the arc
    H = vy**2 / (2 * g)

    # Horizontal range: total distance traveled forward
    R = vx * T

    return T, H, R

def generate_trajectory(vx, vy, T, g=9.81, num_points=500):
    """Compute x and y positions at evenly spaced time steps."""
    # Create 500 evenly spaced time values from launch to landing
    t = np.linspace(0, T, num=num_points)

    # Horizontal position: constant velocity, so x grows linearly
    x = vx * t

    # Vertical position: starts going up, gravity pulls it into a parabola
    y = vy * t - 0.5 * g * t**2

    return t, x, y

# --- Test all three functions ---
angle = 45
v0 = 50

# Step 1: Decompose velocity
vx, vy = decompose_velocity(angle, v0)
print(f"Horizontal velocity (vx): {vx:.2f} m/s")
print(f"Vertical velocity (vy): {vy:.2f} m/s")

# Step 2: Compute flight stats
T, H, R = compute_flight_stats(vx, vy)
print(f"Flight time: {T:.2f} s")
print(f"Max height: {H:.2f} m")
print(f"Range: {R:.2f} m")

# Step 3: Generate trajectory arrays
t, x, y = generate_trajectory(vx, vy, T)
print(f"Trajectory computed: {len(x)} data points")

Your physics engine is complete. You have three functions that turn a launch angle and speed into a full trajectory of 500 data points. Next up, you'll turn those data points into a visual plot with matplotlib.

Visualize the Trajectory

In Step 2, you built a physics engine that computes the full trajectory of a projectile. Right now those results are just arrays of numbers sitting in memory.

In this step, you will turn those numbers into a visual plot using matplotlib. You will plot the parabolic arc, label the axes, and annotate the chart with the max height and landing point so the physics values are visible right on the graph.

In this step, get ready to:
  • Plot the projectile trajectory as a smooth curve with labeled axes.
  • Annotate the plot with the max height and landing point.
  • Run the simulator and experiment with different launch angles.
Plot the trajectory curve

Your simulator.py already computes arrays of x and y positions. Now you need to import matplotlib and use its plotting functions to draw those positions as a curve on a chart.

  • Open simulator.py in your text editor.
  • Add the following import at the top of the file, below your existing import numpy as np line:
import matplotlib.pyplot as plt
  • Scroll to the bottom of simulator.py, below where you call your trajectory function and get back x and y arrays.
  • Add the plotting code by copying the following below your existing code:
# Create a figure with a wide aspect ratio
plt.figure(figsize=(10, 5))

# Plot the trajectory as a smooth curve
plt.plot(x, y, label=f'{angle}° trajectory')

# Label the axes so the chart is readable
plt.xlabel('Distance (m)')
plt.ylabel('Height (m)')
plt.title('Projectile Motion Trajectory')

# Add grid lines for easier reading
plt.grid(True)

# Display the plot in a window
plt.show()

What does this code do?

  • plt.figure(figsize=(10, 5)) creates a new figure that is 10 inches wide and 5 inches tall. The wide shape matches the horizontal nature of a trajectory.
  • plt.plot(x, y) draws a line connecting each (x, y) point. Because you have 500 points from np.linspace, the line looks like a smooth parabolic curve.
  • plt.grid(True) adds faint grid lines behind the curve so you can read approximate values off the chart.
  • plt.show() opens an interactive window displaying the plot. The script pauses here until you close the window.
  • Save simulator.py.

Seeing an import error for matplotlib?

  • Make sure your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
  • If (.venv) is missing, activate it again by running source .venv/bin/activate from inside your projectile-simulator folder.
  • Still stuck? Help me fix my matplotlib import error.
Annotate the max height and landing point

A plain curve is useful, but annotations make the physics values jump off the chart. You will mark two key points: the apex (highest point) and the landing spot (where the projectile hits the ground).

  • In simulator.py, find the line that says plt.grid(True).
  • Add the following annotation code directly below it, before plt.show():
# Mark the apex (max height) on the chart
plt.annotate(
    f'Max Height: {max_height:.1f} m',
    xy=(horizontal_range / 2, max_height),
    xytext=(horizontal_range / 2 + 10, max_height + 5),
    arrowprops=dict(facecolor='black', shrink=0.05),
    fontsize=9
)

# Mark the landing point (range) on the chart
plt.annotate(
    f'Range: {horizontal_range:.1f} m',
    xy=(horizontal_range, 0),
    xytext=(horizontal_range - 30, max_height / 2),
    arrowprops=dict(facecolor='red', shrink=0.05),
    fontsize=9
)

What does plt.annotate do?

  • The first argument is the text label that appears on the chart.
  • xy is the data point the arrow points to. For max height, that is the midpoint of the trajectory at the peak. For range, it is the landing point at y = 0.
  • xytext is where the text label sits. Offsetting it from the data point keeps the label readable.
  • arrowprops draws an arrow from the text to the data point. shrink=0.05 adds a small gap between the arrow tip and the point so they do not overlap.

Where do max_height and horizontal_range come from?

These are the variables you computed in Step 2 using your kinematics function. Make sure those variable names match what you used in your code. If you named them differently (like H and R), update the annotation code to match.

  • Save simulator.py.

Arrow pointing to the wrong spot?

  • Double-check that xy uses data coordinates from your trajectory arrays. The max height occurs at x = horizontal_range / 2 because the trajectory is symmetric.
  • If the text overlaps the curve, adjust the xytext offsets to move the label further away.
  • Still stuck? Help me fix my plt.annotate placement.
Run the simulator and experiment with angles

Time to see your physics in action. You will run the script, view the trajectory plot, and then change the launch angle to see how the parabolic arc changes shape.

  • Run your simulator by executing this command in your terminal:
python3 simulator.py

A window pops up showing the parabolic trajectory curve. You should see a smooth arc starting from the origin, rising to the max height, and landing back at y = 0. Two annotations with arrows point to the apex and the landing spot.

No plot window appearing?

  • Make sure plt.show() is the last line in your script. If any code runs after it, the window may close immediately.
  • On macOS, if you installed Python via Homebrew, you may need to use the TkAgg backend. Add import matplotlib; matplotlib.use('TkAgg') before your import matplotlib.pyplot as plt line.
  • Still stuck? Help me get my matplotlib plot to display.
  • Close the plot window.
  • Back in simulator.py, find the line where you set the launch angle variable (for example, angle = 45).
  • Change the angle to 30, save the file, and run python3 simulator.py again.
  • Try 60 degrees next and compare the shape.

What should I notice at different angles?

  • At 30 degrees, the arc is wide and low. The projectile covers more ground but does not fly very high.
  • At 60 degrees, the arc is tall and narrow. The projectile flies much higher but lands closer.
  • At 45 degrees, you get the maximum range. This is a classic physics result you will confirm visually in the next step.

Awesome, I've got everything!

Double-check you have saved simulator.py with all the plotting and annotation code.

I'd like to double check the full code

Here is the complete simulator.py file with everything from Steps 2 and 3 combined.

import numpy as np
import matplotlib.pyplot as plt

# --- Physics functions from Step 2 ---

def decompose_velocity(angle_deg, v0):
    """Break initial velocity into horizontal and vertical components."""
    angle_rad = np.radians(angle_deg)
    vx = v0 * np.cos(angle_rad)  # horizontal component
    vy = v0 * np.sin(angle_rad)  # vertical component
    return vx, vy

def compute_kinematics(vx, vy, g):
    """Calculate flight time, max height, and horizontal range."""
    flight_time = 2 * vy / g          # total time in the air
    max_height = vy**2 / (2 * g)      # peak height
    horizontal_range = vx * flight_time  # total horizontal distance
    return flight_time, max_height, horizontal_range

def generate_trajectory(vx, vy, g, flight_time):
    """Compute x and y positions at 500 time steps."""
    t = np.linspace(0, flight_time, num=500)
    x = vx * t                        # horizontal position over time
    y = vy * t - 0.5 * g * t**2       # vertical position over time
    return x, y

# --- Configuration ---
angle = 45       # launch angle in degrees
v0 = 50          # initial velocity in m/s
g = 9.81         # gravitational acceleration in m/s^2

# --- Compute trajectory ---
vx, vy = decompose_velocity(angle, v0)
flight_time, max_height, horizontal_range = compute_kinematics(vx, vy, g)
x, y = generate_trajectory(vx, vy, g, flight_time)

# Print the computed physics values
print(f"Launch angle: {angle} degrees")
print(f"Flight time: {flight_time:.2f} s")
print(f"Max height: {max_height:.2f} m")
print(f"Range: {horizontal_range:.2f} m")

# --- Plot the trajectory ---
plt.figure(figsize=(10, 5))
plt.plot(x, y, label=f'{angle}° trajectory')
plt.xlabel('Distance (m)')
plt.ylabel('Height (m)')
plt.title('Projectile Motion Trajectory')
plt.grid(True)

# Annotate the max height
plt.annotate(
    f'Max Height: {max_height:.1f} m',
    xy=(horizontal_range / 2, max_height),
    xytext=(horizontal_range / 2 + 10, max_height + 5),
    arrowprops=dict(facecolor='black', shrink=0.05),
    fontsize=9
)

# Annotate the landing point
plt.annotate(
    f'Range: {horizontal_range:.1f} m',
    xy=(horizontal_range, 0),
    xytext=(horizontal_range - 30, max_height / 2),
    arrowprops=dict(facecolor='red', shrink=0.05),
    fontsize=9
)

plt.show()

Your simulator now produces a real, annotated physics chart from raw equations. Next up, you will plot multiple angles on the same figure and confirm the 45-degree optimum.

Compare Multiple Launch Angles

In the previous step, you plotted a single trajectory and annotated it with key physics values.

One of the most famous results in projectile motion is that a 45-degree launch angle produces the maximum range. In this step, you will prove that result yourself by plotting multiple trajectories on the same figure and printing a comparison table.

In this step, get ready to:
  • Plot trajectories for five different launch angles on a single figure.
  • Print a formatted comparison table of physics values for each angle.
  • Save the multi-trajectory figure as a PNG file.
Loop through angles and plot each trajectory

Instead of calling your trajectory function once, you will loop through a list of angles and plot each result on the same figure. Each curve gets a label so matplotlib can build a legend automatically.

  • Open simulator.py in your editor.
  • Find the section at the bottom of your file where you previously called your trajectory function and created a single plot.
  • Replace that section with the following code:
# Define the angles to compare (in degrees)
angles = [15, 30, 45, 60, 75]

# Initial conditions
v0 = 50   # launch speed in m/s
g = 9.81  # gravitational acceleration in m/s^2

# Create a figure for the multi-trajectory plot
plt.figure(figsize=(10, 5))

# Loop through each angle and plot its trajectory
for angle in angles:
    # Decompose velocity into components
    vx, vy = decompose_velocity(angle, v0)

    # Calculate flight time, max height, and range
    T, H, R = compute_kinematics(vx, vy, g)

    # Generate the trajectory arrays
    x, y = generate_trajectory(vx, vy, g, T)

    # Plot with a label for the legend
    plt.plot(x, y, label=f'{angle}°')

# Add labels, title, legend, and grid
plt.xlabel('Distance (m)')
plt.ylabel('Height (m)')
plt.title('Projectile Trajectories at Different Launch Angles')
plt.legend()
plt.grid(True)
plt.show()

What does this code do?

  • angles holds five launch angles spread from shallow (15°) to steep (75°).
  • The for loop calls the same three functions you already built, once per angle.
  • label=f'{angle}°' tags each curve so plt.legend() can display a color-coded key.
  • All five curves share the same axes, making it easy to compare their shapes at a glance.
  • Save simulator.py.
  • Run the script to verify the multi-trajectory plot by running this command:
python3 simulator.py
  • You should see a plot window with five parabolic arcs, each a different color, and a legend in the corner labeling each angle.

Only seeing one curve?

  • Make sure the plt.figure() call is before the loop, not inside it. Putting it inside creates a new figure on every iteration.
  • Check that plt.show() is after the loop, not inside it. Calling it inside the loop displays a separate window for each angle.
  • Confirm your indentation is correct. All plt.plot() calls should be indented inside the for loop.

Help me debug why only one trajectory curve is showing up in my matplotlib plot.

Print a comparison table

The plot shows the shapes, but exact numbers tell a clearer story. A printed table lets you compare max height, range, and flight time side by side for every angle.

  • In simulator.py, add the following code above the plt.show() line:
# Print a summary table of physics values for each angle
print(f"{'Angle':<10}{'Max Height (m)':<18}{'Range (m)':<15}{'Flight Time (s)'}")
print('-' * 58)

for angle in angles:
    vx, vy = decompose_velocity(angle, v0)
    T, H, R = compute_kinematics(vx, vy, g)

    # Format each value to 2 decimal places, aligned in columns
    print(f"{angle:<10}{H:<18.2f}{R:<15.2f}{T:.2f}")

What does this code do?

  • The f"{'Angle':<10}" syntax left-aligns the text inside a 10-character-wide column. This keeps your table neat.
  • :.2f formats each number to two decimal places.
  • The loop recalculates kinematics for each angle. This is fast because the math is simple arithmetic.
  • Save simulator.py.
  • Run the script again by running this command:
python3 simulator.py
  • You should see a neatly formatted table printed in your terminal before the plot window opens. The 45-degree row should have the largest range value.

Table columns look misaligned?

  • Make sure the column widths in the header line (<10, <18, <15) match the widths in the data row.
  • Check that the f prefix is present at the start of both print strings. Without it, Python prints the curly braces literally.

Help me fix my Python f-string table formatting.

Save the figure to a file

A plot window is great for exploration, but saving the figure as an image file lets you share your results or include them in a report.

  • In simulator.py, find the plt.show() line.
  • Find this line:
plt.show()
  • Add a plt.savefig() call directly above it, so the two lines look like this:
# Save the figure as a PNG image before displaying it
plt.savefig('trajectories.png', dpi=150)
plt.show()

Why does savefig come before show?

plt.show() clears the figure from memory after displaying it. If you call plt.savefig() after plt.show(), you save a blank image.

  • Save simulator.py.
  • Run the script one more time by running this command:
python3 simulator.py
  • After the plot window appears and you close it, confirm the file was saved by running:
ls
  • You should see trajectories.png listed alongside simulator.py in the output.

Don't see trajectories.png?

  • Make sure plt.savefig('trajectories.png', dpi=150) appears before plt.show() in your code.
  • Check that your terminal is in the projectile-simulator folder. The file saves to whatever directory you run the script from.

Help me figure out why plt.savefig isn't creating my PNG file.

Awesome, I've got everything!

Double check that you have saved simulator.py and that trajectories.png exists in your project folder.

I'd like to double check the full code

Here is the complete simulator.py file with all the code from every step combined.

import numpy as np
import matplotlib.pyplot as plt

def decompose_velocity(angle_deg, v0):
    """Break initial velocity into horizontal and vertical components."""
    angle_rad = np.radians(angle_deg)
    vx = v0 * np.cos(angle_rad)  # horizontal component
    vy = v0 * np.sin(angle_rad)  # vertical component
    return vx, vy

def compute_kinematics(vx, vy, g):
    """Calculate flight time, max height, and horizontal range."""
    T = 2 * vy / g               # total flight time
    H = vy**2 / (2 * g)          # maximum height at apex
    R = vx * T                   # horizontal range at landing
    return T, H, R

def generate_trajectory(vx, vy, g, T):
    """Generate arrays of x and y positions over the flight."""
    t = np.linspace(0, T, num=500)  # 500 evenly spaced time points
    x = vx * t                      # horizontal position at each time
    y = vy * t - 0.5 * g * t**2     # vertical position (parabolic arc)
    return x, y

# Define the angles to compare (in degrees)
angles = [15, 30, 45, 60, 75]

# Initial conditions
v0 = 50   # launch speed in m/s
g = 9.81  # gravitational acceleration in m/s^2

# Create a figure for the multi-trajectory plot
plt.figure(figsize=(10, 5))

# Loop through each angle and plot its trajectory
for angle in angles:
    vx, vy = decompose_velocity(angle, v0)
    T, H, R = compute_kinematics(vx, vy, g)
    x, y = generate_trajectory(vx, vy, g, T)
    plt.plot(x, y, label=f'{angle}°')

# Add labels, title, legend, and grid
plt.xlabel('Distance (m)')
plt.ylabel('Height (m)')
plt.title('Projectile Trajectories at Different Launch Angles')
plt.legend()
plt.grid(True)

# Print a summary table of physics values for each angle
print(f"{'Angle':<10}{'Max Height (m)':<18}{'Range (m)':<15}{'Flight Time (s)'}")
print('-' * 58)

for angle in angles:
    vx, vy = decompose_velocity(angle, v0)
    T, H, R = compute_kinematics(vx, vy, g)
    print(f"{angle:<10}{H:<18.2f}{R:<15.2f}{T:.2f}")

# Save the figure as a PNG image before displaying it
plt.savefig('trajectories.png', dpi=150)
plt.show()

You now have a complete projectile motion simulator that computes, compares, and visualizes trajectories across multiple launch angles. Next up, take on the Secret Mission and see what happens when air resistance enters the picture.

Secret mission

Add Air Resistance to Your Simulator

Your simulator works great, but it assumes a perfect vacuum. In the real world, air resistance slows projectiles down and changes their trajectory. Can you model drag and see how reality differs from the ideal case?

Before you go

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The projectile-simulator project folder containing simulator.py and trajectories.png.
  • The Python virtual environment (.venv) with numpy and matplotlib installed.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with different launch angles and drag coefficients.

  • Your project folder, virtual environment, and generated plots will stay on your machine as they are.
  • There are no ongoing costs since everything runs locally.

Pause - I'll come back to this later

Deactivate your virtual environment to free up your terminal session. Your files and installed packages will remain on disk for when you return.

  • Run deactivate in your terminal to exit the virtual environment.
  • When you come back, navigate to your project folder and reactivate with source .venv/bin/activate (macOS/Linux) or .venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your code, virtual environment, and generated plots.

  • Run deactivate to exit the virtual environment if it is still active.
  • Navigate to the parent directory of your project folder.
  • Delete the entire project folder by running rm -rf projectile-simulator (macOS/Linux) or rmdir /s /q projectile-simulator (Windows).
  • This removes simulator.py, the .venv virtual environment, and any generated image files like trajectories.png.

Mission accomplished

That's a Wrap!

Nice work! You just turned abstract physics equations into a working projectile motion simulator with Python.

You've learned how to:

  • Decompose velocity into horizontal and vertical components using trigonometry, then implement kinematics equations to compute flight time, max height, and range.
  • Create annotated trajectory plots with matplotlib that visualize the parabolic arc, apex, and landing point of a projectile.
  • Compare trajectories at multiple launch angles on a single figure and confirm the classic 45-degree optimum for maximum range.
  • Add air resistance using Euler's method and compare real-world drag trajectories against the ideal case.

Ready to quiz yourself?