Manim tutorial

Manim is a Python library that draws math and science animations. You write Python code that says “put a circle here,” “now move it there,” “now turn it into a triangle,” and Manim renders that into an actual video file.

How to read this: each section is a concept, a tiny working example, a plain-English explanation, and two exercises. Sections build on each other in order, so it’s best read top to bottom the first time. Everything in this tutorial is real code.


Table of Contents

  1. Getting Started & Rendering
  2. Your Lego Bricks: Shapes, Colors, and the Coordinate Grid
  3. Moving Things Around: The Positioning Toolbox
  4. Bringing Things to Life: self.play
  5. The Caption Relay: Swapping Text Smoothly
  6. Teams of Shapes: VGroup & Falling Dominoes
  7. The Invisible Remote Control: ValueTracker & Updaters
  8. Live Text Counters: mob.become
  9. Drawing Instantly: Fast Multi-Path Fans
  10. The Sparkler Trail: TracedPath
  11. Arrows With a Mind of Their Own
  12. Graph Paper: Axes, Plotting, and Area Under a Curve
  13. Write Your Own Physics Engine
  14. Morphing: Transform
  15. Faking 3D: The Coin Flip Illusion
  16. The Flying Drone: MovingCameraScene
  17. Video Game HUD & the Shrinking-Zoom Trick
  18. Example 1: The Quantum Slit Experiment
  19. Example 2: A Planet in Orbit

1. Getting Started & Rendering

Before you animate anything, you need a movie set. In Manim, that set is called a Scene. Everything you draw and move happens inside one method called construct. When you’re done writing the code, you don’t press a “play” button. You type a command into your terminal, and Manim renders (builds) your code into an .mp4 video file, frame by frame.

from manim import *
class HelloWorld(Scene):
def construct(self):
circle = Circle(color=BLUE, fill_opacity=0.5)
self.play(Create(circle), run_time=2)
self.wait(1)
# Save this as tutorial.py, then in your terminal:
# manim -pql tutorial.py HelloWorld

Think of -pql as “preview, quality: low.” It builds fast but looks blurry, which is fine while you’re still fixing bugs. -pqh is “quality: high,” for your final export.

Every scene file in the set starts the exact same way:

class KeplersPattern(Scene):
def construct(self):
self.camera.background_color = "#0b0b0f"
...

That self.camera.background_color line is the very first thing to set. It paints the whole background a near-black color before anything else gets drawn, so nothing ever flashes white.

Exercises

  • Speed Test: Render HelloWorld with -pql, then again with -pqh. Time how much longer the high-quality version takes.
  • Ghost Buster: Add --disable_caching to your render command. Manim sometimes remembers old edits and reuses stale frames. This flag forces a fresh build.

2. Your Lego Bricks: Shapes, Colors, and the Coordinate Grid

What it is: Manim’s canvas is a coordinate grid, just like graph paper. The very center of the screen is ORIGIN, the point [0, 0, 0]. Everything you draw needs a shape and, usually, a color.

The basic shapes you’ll use constantly across all scenes:

Dot(ORIGIN, radius=0.16, color="#e8a23d") # a filled circle point
Circle(radius=2.6, color=GREY_C, stroke_width=2,
fill_color="#22303f", fill_opacity=0.9) # a ring, or filled disc
Line(LEFT * 5.5, RIGHT * 5.5, color=GREY_D) # a straight segment
DashedLine(ORIGIN, [1, 0, 0], color=GREY_D) # a dashed segment
Polygon(p1, p2, p3, p4, color=BLUE,
fill_color=BLUE, fill_opacity=0.45) # any closed shape
Triangle(color=GREY_B, fill_color=GREY_B,
fill_opacity=1) # a filled triangle

Manim gives you built-in color names in capital letters: BLUE, RED, GREY_A through GREY_E (a whole ramp of greys, A lightest to E darkest), YELLOW, GREEN, and so on. You can also use a hex code string, the same way you would in web design: "#e8455c". Every scene in this project builds its own small named palette at the top of the file, like:

SUN_COLOR = "#e8a23d"
PLANET_COLOR = "#4fc3f7"
SWEEP_COLOR = "#e8455c"

This is a good habit. Name your colors once, by what they mean (“sun,” “force,” “resolved answer”), and reuse the name everywhere.

The direction words: UP, DOWN, LEFT, RIGHT are actually tiny vectors (arrows with a length of 1), and you can multiply and add them like numbers:

UP * 2.3 # two-and-a-bit units up from wherever you're measuring from
LEFT * 4.5 + UP * 1.7 # up and to the left, combined

Exercises

  • Palette Builder: Make a scene with five Dots in a row, each one a different named color (RED, BLUE, GREEN, YELLOW, PURPLE).
  • Hex Hunter: Pick your favorite color from an online color picker, copy its hex code, and use it to color a Circle.

3. Moving Things Around: The Positioning Toolbox

What it is: Once a shape exists, you have several tools to place it exactly where you want.

dot.move_to(ORIGIN) # teleport to an exact point
dot.move_to([3, 1, 0]) # or an exact [x, y, z] coordinate
dot.next_to(other_dot, UP, buff=0.3) # sit just above another object,
# with a small gap ("buff") between
dot.to_edge(UP, buff=0.6) # stick to the top edge of the screen
dot.shift(RIGHT * 2) # nudge it: move relative to where it already is
dot.scale(0.5) # shrink to half size (or scale up if > 1)
dot.rotate(PI) # spin it, in radians (PI = 180 degrees)

move_to teleports to an absolute spot. shift moves relative to wherever the object currently is: the difference between “stand at the corner of 5th and Main” and “take two steps to your left.”

next_to is the one you’ll use most for labels: “put this text just above that dot.”

label = Text("the sun", color=GREY_D).scale(0.3)
label.next_to(sun_dot, DOWN, buff=0.3)

Exercises

  • Label Everything: Draw three dots in a triangle shape and label each one with next_to.
  • Corner Crawl: Take one square and animate it moving to all four edges of the screen in a row, using .animate.to_edge(...).

4. Bringing Things to Life: self.play

What it is: Just creating a shape with self.add(shape) makes it appear instantly, with no animation. self.play(...) is how you tell Manim “animate this transition, over this many seconds.”

The animation verbs you’ll use over and over:

self.play(Create(circle), run_time=2) # draw its outline, like a pen tracing it
self.play(FadeIn(circle), run_time=1) # appear by fading in
self.play(FadeOut(circle), run_time=1) # disappear by fading out
self.play(Flash(dot, color=RED), run_time=0.5) # a quick starburst flash, good for "impact!"
self.play(dot.animate.move_to(RIGHT * 3)) # animate ANY property change smoothly
self.wait(1) # just pause, nothing moving

That .animate trick is the most powerful one: stick .animate in front of almost any object, then call whatever method you’d normally call, and Manim smoothly tweens (interpolates) from the current state to the new one instead of snapping instantly.

self.play(dot.animate.move_to(RIGHT * 5), rate_func=linear, run_time=2.6)

rate_func controls the pacing of the motion. linear means constant speed, no easing. smooth (the default) eases in and out gently. rush_into starts slow and rushes toward the end; it’s used in the integral scene to make a counter “race” toward its final answer.

You can animate several things in the same self.play call, and they’ll all happen together:

self.play(
FadeIn(sun), FadeIn(sun_glow), Create(orbit_curve), FadeIn(caption),
run_time=1.4,
)

Exercises

  • Race the Rate Functions: Animate the same dot moving the same distance three separate times, using rate_func=linear, then rate_func=smooth, then rate_func=rush_into. Watch how differently each one feels, even though the start and end points never change.
  • Impact Moment: Animate a ball falling, then call Flash the instant it lands.

5. The Caption Relay: Swapping Text Smoothly

What it is: Never let two captions be visible at once. Every time the narration needs to say something new, the old caption fades out at the exact same moment the new one fades in.

caption = Text("Each planet traces an ellipse.", color=GREY_A)
caption.scale(0.48).to_edge(UP, buff=0.6)
self.play(FadeIn(caption), run_time=0.8)
self.wait(1.0)
new_caption = Text(
"The sun sits at one focus — not the center.", color=GREY_A
).scale(0.46).to_edge(UP, buff=0.6)
self.play(FadeOut(caption), FadeIn(new_caption), run_time=0.6)
caption = new_caption # <-- IMPORTANT: reassign the variable!

After the swap, caption now refers to the new text object, so the next swap can fade it out correctly. Forgetting to reassign is an easy bug to make in Manim. You’ll end up trying to fade out something that’s already gone, and Manim gets confused.

The same “old fades out as new fades in” idea also works for whole groups of objects, not just captions. Swap one shape for a completely different one, over and over, without ever leaving stale objects to clutter the screen.

Exercises

  • Five-Line Story: Write a five-caption story (like a joke with a punchline) using this relay pattern, one self.wait() between each line for comic timing.
  • Two-Line Caption: Use \n inside a Text(...) string to make a caption wrap onto two lines, like Text("First line\nSecond line").

6. Teams of Shapes: VGroup & Falling Dominoes

What it is: A VGroup bundles several mobjects (“Manim objects”) into one team, so you can move, color, or animate all of them together with a single command.

squares = VGroup(*[Square(side_length=0.5, color=BLUE) for _ in range(10)])
squares.arrange(RIGHT, buff=0.2) # line them up in a neat row automatically

arrange saves a lot of work. Instead of computing ten x-coordinates by hand, just say “put these in a row,” or arrange(DOWN) for a column.

If you make all ten squares appear at the exact same instant, it looks flat and boring. LaggedStartMap staggers the start time of each one’s animation by a tiny amount, so they cascade like dominoes:

self.play(
LaggedStartMap(FadeIn, squares, shift=DOWN, lag_ratio=0.1),
run_time=2,
)

lag_ratio=0.1 means each square starts its own animation 10% of the total duration after the previous one started. The “same pull, everywhere” scene uses this same idea (with LaggedStartMap(FadeIn, arrows, lag_ratio=0.05)) to make sixteen little particle arrows pop in around a mass one after another, like a burst rather than a single flat pulse.

You can also grab specific members out of a group using conditions. The “why gravity” scene picks out just the arrows pointing toward a second mass, out of a full ring :

shadowed = VGroup()
for i, arrow in enumerate(arrows):
angle = i / n_particles * TAU
arrow_dir = np.array([np.cos(angle), np.sin(angle), 0])
if np.dot(arrow_dir, direction_to_mass2) > 0.5:
shadowed.add(arrow)
self.play(shadowed.animate.set_opacity(0.05), run_time=1.0)

That’s a dot product doing the work (a bit of vector math: it’s large when two directions point roughly the same way, and small or negative when they don’t), answering “which arrows are aimed toward the second mass?” and fading only those out.

Exercises

  • Raindrops: Make 15 dots fall from the top of the screen to the bottom using LaggedStartMap, so they don’t all land at once.
  • Half a Circle: Arrange 12 arrows in a ring pointing outward (like a clock), then use a dot-product filter like the one above to fade out only the ones in the top half.

7. The Invisible Remote Control: ValueTracker & Updaters

What it is: A ValueTracker is an invisible dial. It holds a single number and doesn’t draw anything on screen by itself. An updater is a tiny robot you attach to a shape. Every single frame, that robot checks the dial’s current number and redraws the shape to match.

import numpy as np
beat_dial = ValueTracker(0)
heart = Dot(radius=0.5, color=RED)
self.add(heart)
def pulse_robot(mob):
t = beat_dial.get_value()
scale = 1.0 + 0.3 * np.sin(t * PI * 2)
mob.become(Dot(radius=0.5 * scale, color=RED))
heart.add_updater(pulse_robot)
self.play(beat_dial.animate.set_value(4), run_time=4)
heart.clear_updaters() # ALWAYS turn the robot off when you're done!

That last line is critical. If you forget clear_updaters(), the robot keeps running forever, checking a dial that’s no longer changing, silently wasting time and sometimes causing weird bugs in later parts of your scene.

A planet’s position keeping pace with orbit progress, a rod’s rotation angle in the Cavendish experiment, an arrow that always points from a dot toward a moving target. The Cavendish torsion balance scene, for example, ties a rotation angle to a dial:

theta = ValueTracker(0.0)
def right_end():
a = theta.get_value()
return np.array([L * np.cos(a), L * np.sin(a), 0])
rod = Line(left_end(), right_end(), color=GREY_B)
rod.add_updater(lambda m: m.put_start_and_end_on(left_end(), right_end()))
self.play(theta.animate.set_value(-np.radians(30)), run_time=2.2)

Exercises

  • The Fading Ghost: Change the heartbeat example so the updater changes the dot’s opacity instead of its radius.
  • Double Speed: Change the math inside the sine wave (t * PI * 2t * PI * 4) so the pulse happens twice as fast for the same dial speed.

8. Live Text Counters: mob.become

What it is: Text objects showing numbers (“576 ft”, “160 ft/sec”) can’t just have their number changed. Manim text is drawn as a fixed shape, not a live variable. The fix is the same updater trick from Section 7, but instead of resizing a shape, the robot builds a brand-new Text object every frame and swaps it in with .become().

time_dial = ValueTracker(0.0)
timer_display = Text("0.0 s", color=YELLOW)
self.add(timer_display)
def update_timer(mob):
current_time = time_dial.get_value()
new_text = Text(f"{current_time:.1f} s", color=YELLOW)
new_text.move_to(mob.get_center()) # keep it anchored in the same spot!
mob.become(new_text)
timer_display.add_updater(update_timer)
self.play(time_dial.animate.set_value(5.0), run_time=5)
timer_display.clear_updaters()

The line new_text.move_to(mob.get_center()) matters. Without it, every new number would snap back to the screen’s default center instead of staying where the old one was.

This same trick is used in this Zeno’s Paradox scene:

count_tracker = ValueTracker(528)
counter = Text("528.0 ft", color=TRUE_COLOR).scale(0.55)
def update_counter(m):
val = count_tracker.get_value()
new_text = Text(f"{val:.1f} ft", color=TRUE_COLOR).scale(0.55)
new_text.move_to(m.get_center())
m.become(new_text)
counter.add_updater(update_counter)
self.play(count_tracker.animate.set_value(576), run_time=1.8, rate_func=rush_into)
counter.clear_updaters()

Remember: attach the updater after your FadeIn finishes, not before. Attaching it too early can make the fade-in animation and the .become() rebuild fight over the object’s internal state and crash.

Exercises

  • Countdown: Build a rocket-launch timer counting from 10.0 down to 0.0.
  • Big Numbers: Use Python’s comma formatting (f"{int(val):,}") to build a counter that races up to 1,000,000.

9. Drawing Instantly: Fast Multi-Path Fans

What it is: Animating 50 or 150 separate objects one at a time, step by step, will slow down your computer. The trick is to do all the math first, in plain Python/NumPy (instantly, with no animation involved), and only hand the final points to Manim to draw as one shape.

import numpy as np
points = [np.array([x, y, 0]) for x, y in zip(x_positions, y_positions)]
path = VMobject(stroke_color=BLUE_C, stroke_width=1.5)
path.set_points_as_corners(points) # connect the dots instantly

set_points_as_corners takes a plain list of coordinates and draws straight segments between them, like a connect-the-dots puzzle, computed in one shot instead of animated point by point. This is how the ellipse-tracing math in Kepler’s First Law and the zig-zag particle paths in the quantum slit scene both get built: hundreds of points calculated with numpy, then drawn as a single VMobject.

You can build many of these at once and let Create (or LaggedStartMap(Create, ...)) animate all of them together:

all_paths = VGroup()
for _ in range(50):
... # calculate points for one path
all_paths.add(path)
self.play(Create(all_paths), run_time=2.5)

Exercises

  • Color Variation: Color each path RED if it ends up high on screen and BLUE if it ends up low, based on its last point’s y-coordinate.
  • Burst Effect: Make every path start at the exact center of the screen and shoot outward in a random direction, like fireworks.

10. The Sparkler Trail: TracedPath

What it is: TracedPath is like tying a lit glowstick to a moving dot. It automatically draws a glowing trail behind whatever point you give it, updating every frame, with zero manual math required.

dot = Dot(LEFT * 4, color=YELLOW)
trail = TracedPath(dot.get_center, stroke_color=YELLOW, stroke_width=4)
self.add(trail, dot) # add BOTH: the trail needs to exist before the dot moves
self.play(dot.animate.move_to(UP * 2))
self.play(dot.animate.move_to(RIGHT * 4))

You pass dot.get_center (the function itself, without parentheses), not dot.get_center(). TracedPath calls that function every frame to ask “where are you now?”

A cannonball’s flight path and the “falling sideways” parabola both leave a visible arc behind them as they move:

ball = Dot(path_3d[0], radius=0.07, color=BALL_COLOR)
trail = TracedPath(ball.get_center, stroke_color=BALL_COLOR,
stroke_width=2, stroke_opacity=0.8)
self.add(trail)
self.play(MoveAlongPath(ball, path_curve), rate_func=linear, run_time=duration)

(MoveAlongPath is covered properly in Section 13. It’s how you move a dot along a pre-computed curve rather than a straight line.)

Exercises

  • Loop-de-Loop: Animate a dot moving in a circle (dot.animate.move_to(...) at several points around a circle, or use Rotate) to draw a glowing hula hoop.
  • Stock Market: Move a dot to a series of random y-coordinates, one after another, to draw a jagged trail.

11. Arrows With a Mind of Their Own

What it is: An Arrow is a shape with a start point and an end point. A static arrow just sits there. But combined with the updater trick from Section 7, an arrow can constantly re-aim itself at a moving target, which is useful for force vectors, velocity vectors, or “pull toward the center” diagrams.

pull_arrow = Arrow(color=RED, buff=0, stroke_width=3,
max_tip_length_to_length_ratio=0.35)
def update_pull(m):
direction = -stone.get_center() / np.linalg.norm(stone.get_center())
m.put_start_and_end_on(stone.get_center(), stone.get_center() + direction * 0.7)
pull_arrow.add_updater(update_pull)

put_start_and_end_on(start, end) is the key method: every frame, it redraws the arrow (or a plain Line) between two fresh points. direction is computed as a unit vector (the vector divided by its own length, via np.linalg.norm), so it always has length 1, pointing the right way no matter how close or far the stone currently is. Multiplying that by 0.7 gives an arrow that’s always 0.7 units long and always aimed correctly.

The “whirling stone on a string” scene uses two such arrows at once, one for the inward pull and one for the sideways velocity, both staying attached and correctly aimed as the stone circles around:

def update_vel(m):
angle = stone_angle.get_value()
tangent = np.array([-np.sin(angle), np.cos(angle), 0])
m.put_start_and_end_on(stone.get_center(), stone.get_center() + tangent * 0.9)
vel_arrow.add_updater(update_vel)

That tangent vector [-sin(angle), cos(angle), 0] is a standard trick. For a point moving in a circle, this formula always points in the direction of travel (90 degrees rotated from the “outward” direction). There’s no need to track velocity separately. You can derive it from the current angle.

Exercises

  • Compass Needle: Make an arrow that always points from a moving dot toward a fixed target dot, no matter where the moving dot goes.
  • Two Arrows: Recreate the stone-on-a-string example, one arrow pointing to the center and one pointing tangent to the circle. Watch how they stay perpendicular to each other the whole time.

12. Graph Paper: Axes, Plotting, and Area Under a Curve

What it is: Axes builds you a labeled coordinate system to plot real functions on. It’s the backbone of both the derivative and integral scenes.

axes = Axes(
x_range=[0, 6.5, 1], y_range=[0, 620, 100],
x_length=9, y_length=5,
axis_config={"color": GREY_D, "stroke_width": 2, "include_ticks": False},
)
axes.move_to(ORIGIN)
def s_of_t(t):
return 16 * t ** 2
curve = axes.plot(s_of_t, x_range=[0, 6.3], color=GREY_C, stroke_width=2)
self.play(Create(axes), Create(curve))

x_range/y_range are [start, end, step]. The step just controls default tick spacing, which is often turned off (include_ticks: False) in this project since the scenes prefer clean, uncluttered graphs.

The method to know is axes.c2p(x, y), short for “coordinate to point.” Your function’s math coordinates (like “5 seconds, 400 feet”) aren’t the same as the screen’s actual pixel/unit coordinates, because the axes might be shifted, scaled, or positioned anywhere. c2p does that translation for you:

t0 = 5.0
s0 = s_of_t(t0)
anchor = Dot(axes.c2p(t0, s0), radius=0.09, color=BLUE)

Any time you want to draw something (a dot, a line, a secant) at a specific data point on your graph, you run it through axes.c2p() first.

Filling the area under a curve (used for the integral scene’s final “smooth area” reveal) is one call:

area = axes.get_area(curve, x_range=[0, 6], color=GREEN, opacity=0.4)
self.play(FadeIn(area))

Exercises

  • Plot Your Own: Graph y = x**2 from x=-3 to 3 using axes.plot, and mark the point at x=2 with a Dot placed via axes.c2p.
  • Shade a Region: Use get_area with a restricted x_range (like [1, 2]) to shade just one slice of the curve instead of the whole thing.

13. Write Your Own Physics Engine

Some of the most impressive shots aren’t hand-animated but simulated. Real physics math, run in a plain Python loop, computed before any animation happens. Manim then draws the result.

This is the same “do the math first, animate second” idea from Section 9, applied to motion under a changing force instead of a fixed set of points.

Here’s the core idea is that a symplectic Euler integrator (a fancy name for “take tiny time steps, and at each step, update velocity from acceleration, then update position from velocity”):

def simulate_shot(v0, g, R_launch, R_ground, dt=0.01, max_steps=4000):
p = np.array([0.0, R_launch]) # starting position
vel = np.array([v0, 0.0]) # starting velocity
path = [p.copy()]
for step in range(max_steps):
r = np.linalg.norm(p)
accel = -g * p / r # gravity always points toward the center
vel = vel + accel * dt # tiny nudge to velocity
p = p + vel * dt # tiny nudge to position
path.append(p.copy())
if np.linalg.norm(p) < R_ground: # hit the ground?
break
return np.array(path)

Why “tiny steps” instead of one big calculation? Because gravity’s direction keeps changing as the object moves (it always points toward the planet’s center). There’s no simple formula for the whole path at once, so the computer approximates it by taking thousands of small, fast steps.

Once you have the list of points, you convert it to a Manim-ready path (the same set_points_as_corners trick from Section 9) and animate a dot moving along it with MoveAlongPath:

path_curve = VMobject()
path_curve.set_points_as_corners([[x, y, 0] for x, y in path_pts])
ball = Dot(path_curve.get_start(), radius=0.07, color=BLUE)
self.add(ball)
self.play(MoveAlongPath(ball, path_curve), rate_func=linear, run_time=3.0)

MoveAlongPath is different from dot.animate.move_to(...): move_to always travels in a straight line to the destination, no matter what. MoveAlongPath follows the exact curve of an already-drawn path, which matters for anything that isn’t a straight line, like an orbit or a cannonball’s arc.

The Kepler orbit scene uses a related trick. Instead of simulating position directly, it simulates how angle changes with time (because “equal areas in equal times” is a statement about that relationship), and builds a lookup table. It then reads any position back out with np.interp (linear interpolation, meaning “estimate a value that falls between two known table entries”):

k = 1.0
dtheta = 0.0015
thetas, times = [0.0], [0.0]
theta, t = 0.0, 0.0
while theta < TAU:
r = r_of_theta(theta)
dt = dtheta * r ** 2 / k # this line encodes "equal areas in equal times"
t += dt
theta += dtheta
thetas.append(theta)
times.append(t)
def theta_at_time(tt):
return np.interp(tt % period, np.array(times), np.array(thetas))

Then a ValueTracker for elapsed time, plus an updater, plug straight into everything you learned in Section 7 to move the planet dot smoothly along its (physically correct, non-uniform) speed.

Exercises

  • Drop a Ball: Simulate free-fall under constant gravity (accel = [0, -9.8], no “always point at center” needed) for two seconds, using the same tiny-step loop, then animate a dot along the resulting path.
  • Bouncing Ball: Modify the loop so that when the simulated ball’s height goes below zero, its vertical velocity flips sign (multiply by -0.8 to lose a little energy each bounce) instead of stopping. Watch it bounce.

14. Morphing: Transform

Transform smoothly morphs one mobject’s shape into another’s, in place. That’s different from FadeOut + FadeIn, which makes one object disappear and a completely different one appear. Transform visually stretches, bends, and recolors the same object into its new form.

new_secant = Line(p0, p1, color=RED, stroke_width=stroke_w)
self.play(Transform(secant, new_secant), run_time=1.1)
secant = new_secant # same variable-reassignment habit as Section 5!

This is how the derivative scene makes its secant line visibly “snap” toward the true tangent line as the interval shrinks: same object, quietly becoming more accurate every step, then finally recoloring from red (“provisional guess”) to green (“resolved, exact answer”) the instant it becomes the true tangent:

tangent = Line(tangent_p0, tangent_p1, color=GREEN, stroke_width=stroke_w)
self.play(Transform(secant, tangent), FadeOut(far_dot), run_time=1.0)

The “sideways push” scene uses the same trick to turn a hand holding a string into the sun at the center of an orbit: same visual object, new meaning:

new_hand = Dot(ORIGIN, radius=0.15, color=SUN_COLOR)
self.play(Transform(hand, new_hand), run_time=0.9)

Rule of thumb: use Transform when you want the audience to feel “this is still the same thing, just becoming more accurate or different.” Use FadeOut+FadeIn (Section 5) when you want “this is a totally new, unrelated thing.”

Exercises

  • Square to Circle: Transform a Square into a Circle of the same color, and watch how Manim automatically interpolates the corners into curves.
  • Color Reveal: Transform a grey, dashed guess-line into a solid, brightly-colored “correct answer” line, exactly like the derivative scene’s secant-to-tangent moment.

15. Faking 3D: The Coin Flip Illusion

What it is: You don’t need a real 3D engine to fake a spinning coin. Squish a circle’s horizontal width down to almost nothing, and it reads as “edge-on,” exactly like a coin flipping through the air.

coin = Circle(radius=1.5, color=YELLOW, fill_opacity=1)
self.add(coin)
self.play(coin.animate.stretch(0.04, dim=0), run_time=0.5) # squish to 4% width
self.play(coin.animate.stretch(25, dim=0), run_time=0.5) # 0.04 * 25 = back to 100%

stretch(factor, dim=0) scales only one axis: dim=0 is horizontal (x), dim=1 is vertical (y). The second stretch uses 25, not 1, because you’re scaling from the already-squished state, and 0.04 * 25 = 1.0 gets you back to full width.

Exercises

  • Triple Flip: Loop the squish/unsquish three times in a row for a coin that flips multiple times before landing.
  • Heads and Tails: Put a letter “H” on the coin’s face, and swap it for “T” (using the FadeOut/FadeIn caption-relay trick from Section 5) at the exact moment it’s squished flat.

16. The Flying Drone: MovingCameraScene

What it is: A regular Scene has a camera bolted to a tripod; it never moves. A MovingCameraScene gives you a camera on a drone: self.camera.frame is itself an object you can move, and, importantly, zoom, by changing its width.

class CameraFlight(MovingCameraScene):
def construct(self):
self.camera.frame.move_to(start_dot).set(width=4) # start zoomed in
self.play(
self.camera.frame.animate.move_to(end_dot).set(width=2),
run_time=3,
)

A smaller width value means you’re more zoomed in (you’re looking at a narrower slice of the world, blown up to fill the screen). A larger width zooms out.

This tool is what makes Zeno’s paradox and the zoom into a derivative work. Both scenes repeatedly narrow width around a shrinking gap, so the audience’s eye is dragged closer and closer into an ever-smaller region, mirroring the math getting more precise.

Exercises

  • Three Stops: Add a third dot far to the right and make the camera drone visit all three dots in sequence.
  • Pull Back: After zooming all the way in on one dot, animate the camera back out to a wide width=20 so the audience sees the whole scene again.

17. Video Game HUD & the Shrinking-Zoom Trick

What it is: Once your camera starts moving and zooming (Section 16), a problem appears. Ordinary text and shapes zoom with the camera. A caption that looked normal-sized suddenly becomes gigantic (or tiny) as the frame shrinks. You need a “HUD,” short for heads-up display, that stays a fixed, readable size no matter how the camera moves, the same way a health bar stays put in a video game.

The fix is one more updater (Section 7), attached to the text, that re-reads the camera’s current frame every single frame and re-pins itself:

def pin_to_hud(mob):
frame = self.camera.frame
mob.scale_to_fit_width(frame.width * 0.6) # always take up 60% of the visible width
mob.move_to(frame.get_bottom() + UP * frame.height * 0.1) # stay near the bottom
caption.add_updater(pin_to_hud)
pin_to_hud(caption) # call it once immediately too, so it's placed correctly right away

Calling the function once by hand (pin_to_hud(caption)) in addition to attaching it as an updater is a small but useful habit. The updater only runs starting from the next frame, so without that manual call, the caption would appear in the wrong spot for one frame before snapping into place.

Then rescale everything else. It’s not just text that needs to be rescaled during a zoom. Line thickness (stroke_width) is measured in fixed screen pixels, not world units, so as the camera zooms in, a normal line looks proportionally thicker and thicker, eventually swallowing the whole shot in a solid color bar if you don’t correct for it. The fix, used in both the Zeno and derivative scenes, is to recompute stroke width and dot size as a fraction of how much you’ve zoomed:

stroke_scale = zoom_width / INITIAL_FRAME_WIDTH
target_stroke = max(2 * stroke_scale, 0.05) # never let it hit exactly zero
target_dot_width = zoom_width * 0.035
self.play(
spine.animate.set_stroke(width=target_stroke),
achilles.animate.set(width=target_dot_width),
self.camera.frame.animate.set(width=zoom_width),
run_time=0.9,
)

Exercises

  • Scoreboard Corner: Modify pin_to_hud to stick text in the top-right corner instead of the bottom-center.
  • Zoom Without Breaking: Draw a Line and a Dot, then zoom the camera to width=0.5 (very close). Without the rescaling trick, watch how huge and blobby they become. Then add the rescaling and compare.

18. Example 1: The Quantum Slit Experiment

What it is: A single scene that combines Section 9’s “compute-then-draw” trick with real (simplified) physics, to visualize a well-known idea. When a particle’s position is measured very precisely (a narrow slit), its momentum becomes correspondingly uncertain (it scatters wide). That’s the Heisenberg uncertainty principle, in one picture.

import numpy as np
from manim import *
class TheSlitExperiment(Scene):
def construct(self):
self.camera.background_color = "#0b0b0f"
np.random.seed(23) # freeze the "randomness" so it's the same every render
N_PARTICLES = 40
SOURCE_X, SLIT_X, SCREEN_X, Y_RANGE = -4.5, 0, 4.2, 5.5
slit_height = 0.4
kick_strength = 0.6 / slit_height # narrower slit -> bigger random kick
y_gaps = np.random.uniform(-slit_height / 2, slit_height / 2, size=N_PARTICLES)
kicks = np.random.normal(0, kick_strength, size=N_PARTICLES)
landings = np.clip(y_gaps + kicks, -Y_RANGE, Y_RANGE)
particle_paths = VGroup()
for i in range(N_PARTICLES):
points = [[SOURCE_X, 0, 0], [SLIT_X, y_gaps[i], 0], [SCREEN_X, landings[i], 0]]
path = VMobject(stroke_color=PURPLE, stroke_width=1)
path.set_points_as_corners(points)
particle_paths.add(path)
self.play(LaggedStartMap(Create, particle_paths, lag_ratio=0.02), run_time=2.0)
self.wait(2)

Every ingredient here is from earlier sections: np.random for the physics, set_points_as_corners (Section 9) to draw each particle’s path instantly, np.clip as an invisible wall so particles can’t fly off-screen, and LaggedStartMap (Section 6) so all 40 particles fire in a cascading burst rather than all at once.

Exercises

  • Wide Slit: Change slit_height to 2.0 and watch kick_strength shrink accordingly. A wider slit means much less uncertainty, so particles land in a tight cluster instead of scattering.
  • Landing Dots: After the paths finish drawing, use a loop and LaggedStartMap(FadeIn, ...) to fade in a Dot at each particle’s landing spot on the screen.

19. Example 2: A Planet in Orbit

What it is: A second scene, combining Section 2 (shapes), Section 5 (caption relay), Section 7 (ValueTracker/updaters), Section 9 (fast path drawing), and Section 13 (writing your own physics loop) into a single self-contained scene: an ellipse, a sun off-center at one focus, and a planet that visibly speeds up near the sun and slows down far away. This is how orbits actually work:

from manim import *
import numpy as np
class MiniOrbit(Scene):
def construct(self):
self.camera.background_color = "#0b0b0f"
a, e = 3.0, 0.6 # semi-major axis, eccentricity
def r_of_theta(theta):
return a * (1 - e**2) / (1 + e * np.cos(theta))
# Section 9: pre-compute the ellipse's points, draw instantly
ellipse = ParametricFunction(
lambda th: np.array([r_of_theta(th) * np.cos(th),
r_of_theta(th) * np.sin(th), 0]),
t_range=[0, TAU], color=GREY_C,
)
sun = Dot(ORIGIN, radius=0.16, color="#e8a23d")
# Section 13: simulate constant areal velocity (Kepler's 2nd Law)
k, dtheta = 1.0, 0.0015
thetas, times, theta, t = [0.0], [0.0], 0.0, 0.0
while theta < TAU:
r = r_of_theta(theta)
dt = dtheta * r**2 / k
t += dt; theta += dtheta
thetas.append(theta); times.append(t)
thetas, times = np.array(thetas), np.array(times)
period = times[-1]
def theta_at_time(tt):
return np.interp(tt % period, times, thetas)
# Section 7: a dial driving the planet's position
planet = Dot(radius=0.09, color="#4fc3f7")
th0 = theta_at_time(0) # give it a correct starting spot before the
planet.move_to([r_of_theta(th0) * np.cos(th0), # updater is attached.
r_of_theta(th0) * np.sin(th0), 0]) # don't rely on the
# updater firing first.
time_tracker = ValueTracker(0)
def update_planet(m):
th = theta_at_time(time_tracker.get_value())
m.move_to([r_of_theta(th) * np.cos(th), r_of_theta(th) * np.sin(th), 0])
planet.add_updater(update_planet)
self.play(Create(ellipse), FadeIn(sun), FadeIn(planet), run_time=1.5)
self.play(time_tracker.animate.set_value(period), run_time=6, rate_func=linear)
planet.clear_updaters()
self.wait(1)

Render it, and the planet visibly races through the part of the ellipse closest to the sun, then crawls through the far side, with no hand-tuned speed curve anywhere in the code. That behavior falls straight out of the physics loop.

Exercises

  • Change the Shape: Try e = 0.1 (a much rounder orbit) versus e = 0.85 (a very stretched one) and see how dramatically the speed variation changes.
  • Add the Sweep: Using the Polygon trick from Section 2, shade two thin wedges, one near the sun and one far away, covering equal time intervals (use theta_at_time). Confirm by eye that they look like roughly the same area despite very different shapes.

Happy animating.

Leave a comment

Design a site like this with WordPress.com
Get started