Build an Animation Engine
Animation looks like magic because each frame is simple; the magic is the backstage math connecting frame to frame. This project builds that backstage in pure Python: smoothstep easing between two numbers, sprites carrying velocity and bouncing off walls of a 30×10 canvas, a fixed-timestep engine that steps the whole scene each frame, keyframed paths with eased interpolation, and frames exported as text files you can replay. The engine runs deterministically, the same dots land in the same cells every time, so you can verify every claim in this guide before you make the dots dance. It is a text-first engine: the “video” is a stack of .txt frames you can paste anywhere.
This assumes classes and methods plus basic arithmetic with floats. It is an optional, ungraded project, see Real-World Projects for the full, growing list.
🎯 What you’ll do
- Write the math helpers:
clamp,lerp, and a smoothstep easing curve. - Define a
Spritethat moves with velocity and bounces off canvas edges. - Build a
Scenethat renders sprites onto a text grid, and anEnginethat steps and prints frames. - Add keyframed motion paths so a sprite eases along a route instead of drifting.
- Export the frames to files and reassemble them as a filmstrip.
Where to run this
Locally with uv is the recommended path, the engine is pure Python (only pathlib is needed), so uv init gives you everything.
Google Colab, Kaggle Notebooks, and Binder run every step unmodified, the canvas and easing are math and strings only, no platform-specific calls, and a cell-by-cell notebook suits the frame-by-frame design well.
Setup
Everything needed before the first frame exists.
Set up the project
uv init animation-engine
cd animation-engine
No dependencies. The canvas is a grid of strings; the export writes plain text files.
✅ Checklist
- ✅
uv init animation-enginecreates the project and amain.py. - ✅
uv run python3 -c "from pathlib import Path"succeeds (pathlib is the only import).
🤔 Socratic Question(s)
- A canvas of dots with a moving character is boring, but every rendering engine, from this one to film, is just “a grid, updated at a fixed rate.” What makes the math between updates, not the grid, the actual engine?
- The project works in a notebook, yet you export frames as text files. What does a movie of 10 rows of dots buy you that a live render loop can’t, and what would you lose going the other direction?
Step 1: The math behind motion
Every animation reduces to tiny numeric questions: “move from 0 to 10, but how far along am I halfway through?” Step 1 writes the three answers you’ll reuse everywhere.
1.1 Clamp, lerp, and smoothstep
👟 Starter hint: Write clamp(v, lo, hi), lerp(a, b, t), and smoothstep(t), the last one is the famous ease-in-out curve t²·(3 − 2t).
# main.py
def clamp(v, lo, hi):
return max(lo, min(hi, v))
def lerp(a, b, t):
return a + (b - a) * t
def smoothstep(t):
t = clamp(t, 0.0, 1.0)
return t * t * (3 - 2 * t)
print("clamp(13, 0, 10) =", clamp(13, 0, 10))
print("lerp(0, 10, 0.5) =", lerp(0, 10, 0.5))
print("smoothstep(0, .25, .5, .75, 1):",
smoothstep(0), smoothstep(0.25), smoothstep(0.5), smoothstep(0.75), smoothstep(1))
lerp(a, b, t) is the workhorse: at t=0 you’re at a, at t=1 at b, and linearly in between. smoothstep is the easing personality: it still maps 0→0 and 1→1, but it spends the middle of the motion fast and the very beginning and end slow, smoothstep(0.5) returns exactly 0.5, yet smoothstep(0.25) is only 0.15625, so it lingers, then catches up. That asymmetry is what makes eased motion feel alive instead of mechanical.
🎯 Expected output:
clamp(13, 0, 10) = 10
lerp(0, 10, 0.5) = 5.0
smoothstep(0, .25, .5, .75, 1): 0.0 0.15625 0.5 0.84375 1.0
🩹 If it’s off: If smoothstep(0.5) isn’t 0.5, check the exponent, t*t*(3-2*t) not t*t*t. If values print as 0 with no decimals, the args were ints and integer division snuck in somewhere, feed floats.
1.2 Ease a whole trajectory
👟 Starter hint: Chain smoothstep into lerp so a motion follows the curve rather than a straight line.
# main.py (continued)
def eased_lerp(a, b, t):
return lerp(a, b, smoothstep(t))
print("eased_lerp(0, 10, .5) =", eased_lerp(0, 10, 0.5))
print("eased_lerp(0, 10, .25) =", eased_lerp(0, 10, 0.25))
eased_lerp matches the smoothstep sample above: at t=0.25 you’ve only covered 1.5625 of the 10-unit trip, not 2.5. The dot starts slow, accelerates through the middle, and decelerates at the end.
🎯 Expected output:
eased_lerp(0, 10, .5) = 5.0
eased_lerp(0, 10, .25) = 1.5625
🩹 If it’s off: If eased_lerp(0, 10, .25) prints 2.5, you called lerp(a, b, t) directly, skipping the easing.
1.3 Verify the math
✅ Checklist
- ✅
clamp(13, 0, 10) == 10,clamp(-4, 0, 10) == 0. - ✅
smoothstepmaps 0→0, 1→1, 0.5→0.5 and is symmetrical about the middle. - ✅
eased_lerpand the puresmoothstepnumbers agree.
🤔 Socratic Question(s)
smoothstepis symmetric:smoothstep(0.25) == 1 - smoothstep(0.75)(here0.84375). What real-world motion feels like that, accelerate, cruise, slow, and what curve would you pick instead for a throw, where the start is fast and the landing is a smash?clamp(t, 0, 1)insidesmoothstepsilently fixes out-of-range input. Why is silently-fix fine for easing a dot, but dangerous if the same clamp hid a bug in, say, an animation of a safety-critical dial?
Step 2: Sprites, things that move
Math moves numbers; sprites move things. Step 2 gives each thing a position, a velocity, and a character, and says “step me forward by dt seconds.”
2.1 The Sprite class
👟 Starter hint: Write Sprite(ch, x, y, vx=0.0, vy=0.0) with an update(dt) that integrates position: x += vx · dt.
# main.py (continued)
class Sprite:
def __init__(self, ch, x, y, vx=0.0, vy=0.0):
self.ch = ch
self.x, self.y = float(x), float(y)
self.vx, self.vy = float(vx), float(vy)
def update(self, dt):
self.x += self.vx * dt
self.y += self.vy * dt
s = Sprite("o", 0.0, 5.0, vx=4.0)
for _ in range(5):
s.update(0.125)
print(round(s.x, 3), round(s.y, 3))
x += vx * dt is Euler integration: position advances by velocity times elapsed time. Small dt = smooth motion; dt is the fixed timestep you’ll standardize in Step 3. Position is kept as a float here and only snapped to grid cells at render time, that float is the “between-frames” truth the grid can’t hold.
🎯 Expected output: 2.5 5.0, five steps of 0.125s at 4 units/s travel 5 × 0.5 = 2.5 units, exactly.
🩹 If it’s off: If the output is 0.0 5.0, update never ran (loop indented wrong) or vx was never set. If 40.0, dt was 1.0, you passed the frame count as time.
2.2 Wall bounces
👟 Starter hint: Add fixed canvas bounds (W=30, H=10) to Sprite; in update, clamp position and reverse velocity on contact.
# main.py (continued)
class Sprite:
W, H = 30, 10
def __init__(self, ch, x, y, vx=0.0, vy=0.0):
self.ch = ch
self.x, self.y = float(x), float(y)
self.vx, self.vy = float(vx), float(vy)
def update(self, dt):
self.x += self.vx * dt
self.y += self.vy * dt
self.x = clamp(self.x, 0.0, self.W - 1)
self.y = clamp(self.y, 0.0, self.H - 1)
if self.x == 0.0 or self.x == self.W - 1:
self.vx = -self.vx
if self.y == 0.0 or self.y == self.H - 1:
self.vy = -self.vy
b = Sprite("*", 15.0, 2.0, vy=2.0)
for step in range(8):
b.update(0.125)
if step in (4, 7):
print("step", step + 1, "y =", round(b.y, 3), "vy =", b.vy)
Clamping keeps the sprite on-canvas; testing equality with 0.0 or W-1 flips velocity exactly once per contact. The * drops from y=2.0, and because it travels 0.25 units per frame, it reaches the floor (y=9) cleanly and flips to rising.
🎯 Expected output:
step 5 y = 3.25 vy = 2.0
step 8 y = 4.0 vy = 2.0
(The bounce lands later in the run, Step 3’s scene shows it.)
🩹 If it’s off: If y stops at 9.0 forever, vy flips but then flips back next frame, the equality check fires every frame while resting against the wall. The position must leave the wall before the check re-arms (it does here, because velocity reverses).
2.3 Verify the sprite
✅ Checklist
- ✅
Sprite("o", 0, 5, vx=4)advances2.5after five0.125steps. - ✅ A sprite with negative
vxmoves left and bounds atx=0. - ✅ On wall contact the velocity flips exactly once, and the sprite travels back inward.
🤔 Socratic Question(s)
- The sprite only collides with walls, not with other sprites. What extra test does two-sprite collision need that wall collision doesn’t, and which of
x == 0vsabs(x - wall) < epswould you want for it? - Position is a float; rendering snaps to cells. If velocity is
1anddtis0.125, the dot appears to “skip” every 8 frames. Is that smooth or jagged at 8fps, and what two knobs could you turn to make it smoother?
Step 3: Scenes and the engine loop
One sprite is a bounce. Many sprites on one grid, stepped together at a fixed rate, is an animation. Step 3 adds the Scene (grid + sprites) and the Engine (fixed timestep driver).
3.1 Render a scene to text
👟 Starter hint: Write Scene.render() returning a list of strings, a dot-filled grid with each sprite stamped at its (rounded) cell.
# main.py (continued)
class Scene:
def __init__(self, W=30, H=10):
self.W, self.H = W, H
self.sprites = []
def add(self, sprite):
sprite.W, sprite.H = self.W, self.H
self.sprites.append(sprite)
return self
def step(self, dt):
for sprite in self.sprites:
sprite.update(dt)
def render(self):
grid = [["."] * self.W for _ in range(self.H)]
for sprite in self.sprites:
gx, gy = int(sprite.x + 0.5), int(sprite.y + 0.5)
grid[gy][gx] = sprite.ch
return ["".join(row) for row in grid]
scene = Scene()
scene.add(Sprite("o", 0.0, 5.0, vx=4.0))
print("\n".join(scene.render()))
int(x + 0.5) is round-half-up snapping: floats on the wall boundary land on the nearest cell deterministically. Scene.add assigns its own W/H to each sprite so bounce bounds always match the canvas, no matter what the sprite was constructed with.
🎯 Expected output:
..............................
..............................
..............................
..............................
..............................
o.............................
..............................
..............................
..............................
..............................
🩹 If it’s off: If o is elsewhere, its y is not 5.0. If the grid shows 10 rows of 30 dots the scene is fine, that’s the empty canvas.
3.2 The fixed-timestep engine
👟 Starter hint: Write Engine(scene, fps=8) whose play(frames) steps the scene by dt = 1/fps and returns a list of rendered frames.
# main.py (continued)
class Engine:
def __init__(self, scene, fps=8):
self.scene = scene
self.fps = fps
self.dt = 1.0 / fps
def play(self, frames):
out = []
for _ in range(frames):
self.scene.step(self.dt)
out.append(self.scene.render())
return out
scene = Scene().add(Sprite("o", 0.0, 5.0, vx=4.0)).add(Sprite("*", 15.0, 2.0, vy=2.0))
frames = Engine(scene).play(12)
print("\n".join(frames[4]))
print("-" * 30)
print("\n".join(frames[11]))
play is the whole reel: fps fixes dt, so 8 frames = 1 second, and the same scene replayed with the same parameters produces the same frames, determinism you can test. Frame 5 is just before and frame 12 is a landmark moment for both sprites.
🎯 Expected output: frame 5 (frames[4]) shows o in column 3 (after 4 × 0.5 = 2.0 → 2.5 → snaps to 3) and * in row 3; frame 12 (frames[11]) shows o in column 6 and * in row 5.
🩹 If it’s off: If the two sprites overlap in an unexpected cell, one of them has a velocity direction contradiction. If frames come back stale, scene.step is mutating a copy of the scene, not the same object.
3.3 Verify the engine
✅ Checklist
- ✅
play(12)with the scene above returns 12 frames; frame 5 and frame 12 match the expected columns/rows. - ✅
Engine(scene, fps=8).dt == 0.125. - ✅ Running
playtwice on a fresh scene yields byte-identical frames.
🤔 Socratic Question(s)
dtis1/fps, but the loop steps the scene then prints. Once you’ve stepped, is frame 1 “the state after 0.125s” or “at time 0”? Choose the semantic and defend the off-by-one you settled on.- The engine returns frames as a list, never printing them. Why is the data (frames) the product here, and the screen just a consumer, what does that decoupling let you later swap in?
Step 4: Keyframed paths
Velocity gives you straight lines and bounces. Real animation blocks motion into keyframes, poses at chosen moments, and fills the in-between with eased interpolation. Step 4 adds the path follower.
4.1 Sample along a path
👟 Starter hint: Write Keyframed(ch, keys) where keys is a list of (t, (x, y)) stops; sample(t) finds the segment containing t and eases across it.
# main.py (continued)
class Keyframed:
def __init__(self, ch, keys):
self.ch = ch
self.keys = keys
self.x, self.y = keys[0][1]
def sample(self, t):
for i in range(len(self.keys) - 1):
t0, p0 = self.keys[i]
t1, p1 = self.keys[i + 1]
if t0 <= t <= t1:
u = smoothstep((t - t0) / (t1 - t0))
self.x = lerp(p0[0], p1[0], u)
self.y = lerp(p0[1], p1[1], u)
return (self.x, self.y)
return self.keys[-1][1]
node = Keyframed("A", [(0.0, (0, 0)), (1.0, (10, 2)), (2.0, (10, 8))])
print("t=0.5 ", node.sample(0.5))
print("t=1.0 ", node.sample(1.0))
print("t=2.0 ", node.sample(2.0))
The segment scan finds the two keyframes bracketing t, rescales t into that segment (u), eases u, and lerps both coordinates. A path is data, a list of (time, position), and sample is the pure function that turns time into a pose. After t=1.0 the route bends from moving-right to moving-down, and sample handles the handoff.
🎯 Expected output:
t=0.5 (5.0, 1.0)
t=1.0 (10.0, 2.0)
t=2.0 (10.0, 8.0)
🩹 If it’s off: If t=0.5 returns (5.0, 0.0), the y segment crossed keyframes early. If samples after t=2.0 error, sample falls through to self.keys[-1][1] only when the loop finds no segment, confirm the final keyframe’s time is 2.0, not < 2.0.
4.2 Render a path as a reel
👟 Starter hint: Loop t = 0 … 2 at the engine’s dt, sample the path, stamp the node on a fresh grid, and collect frames.
# main.py (continued)
frames = []
for f in range(17):
_x, _y = node.sample(f * 0.125)
grid = [["."] * 30 for _ in range(10)]
grid[int(_y + 0.5)][int(_x + 0.5)] = node.ch
frames.append(["".join(r) for r in grid])
print("\n".join(frames[0]))
print("-" * 30)
print("\n".join(frames[16]))
Frame 0 is the pose at t=0: A at the top-left. Frame 17 is t=2.0: A at row 8, column 10. Because sample eased both segments, the node lingers at the corners and darts through the straightaways.
🎯 Expected output: frame 0 has A at top-left; frame 16 has A in row 8 (of 0–9), column 10.
🩹 If it’s off: If A never leaves the top-left, sample was looped with t as a frame index instead of f * dt. If it lands at (10, 2) and stops, the second segment’s end time exceeded the loop’s t range.
4.3 Verify the path
✅ Checklist
- ✅
sample(0.5)on the two-segment path returns(5.0, 1.0), the eased midpoint of segment one. - ✅
sample(1.5)lies on the second segment (between(10, 2)and(10, 8)). - ✅ Sampling past the last keyframe returns the final pose, no crash.
🤔 Socratic Question(s)
- The path has no velocities, only times and poses. Why is a pose-only keyframe easier to author than a velocity-only one, and what’s the tradeoff for motion where you want an explicit fly-in speed?
- Smoothstep is applied per-segment, so the node “eases” at both ends of the whole route. Watch the corner at
t=1.0: does it ever move at max speed, and does that match how a real camera cuts between shots?
Step 5: Export the reel
A list of grids in memory is fine; a directory of numbered frames is deliverable. Step 5 writes the frames and reassembles them as a filmstrip.
5.1 Save frames to files
👟 Starter hint: Use pathlib to write each frame as frame_000.txt, padded to three digits, and return the count.
# main.py (continued)
import pathlib
def save_frames(frames, outdir):
outdir = pathlib.Path(outdir)
outdir.mkdir(exist_ok=True)
for i, frame in enumerate(frames):
(outdir / f"frame_{i:03d}.txt").write_text("\n".join(frame) + "\n")
return len(frames)
count = save_frames(frames, "reel")
print("wrote", count, "files")
print(list(pathlib.Path("reel").glob("frame_*.txt"))[:3])
f"frame_{i:03d}" is the zero-padding that makes files sort correctly (frame_009 before frame_010), so any glob or ls reproduces chronological order. The returned count lets a pipeline verify the write: 17 frames in, 17 files out.
🎯 Expected output:
wrote 17 files
[PosixPath('reel/frame_000.txt'), PosixPath('reel/frame_001.txt'), PosixPath('reel/frame_002.txt')]
🩹 If it’s off: If a second run says “already 17 files”, mkdir(exist_ok=True) is missing (or old frames linger and double up). If the glob is empty, the cwd differs from outdir, check which directory save_frames actually wrote into.
5.2 Reassemble a filmstrip
👟 Starter hint: Write read_reel(outdir) that loads the numbered frames back in order and concatenates them with | separators so a glance shows motion across time.
# main.py (continued)
def read_reel(outdir):
outdir = pathlib.Path(outdir)
files = sorted(outdir.glob("frame_*.txt"))
frames = [f.read_text().splitlines() for f in files]
rows_in = len(frames[0])
return [" ".join(frames[i][row] for i in range(len(frames)))
for row in range(rows_in)]
film = read_reel("reel")
print("\n".join(film))
The filmstrip transposes rows: every frame’s top row on line 1, then each frame’s next row on line 2, so a 17-frame reel renders as a wide band you can scroll horizontally and see the dot travel left to right. sorted on the zero-padded names guarantees frame order without sorting logic of your own.
🎯 Expected output: A 10-row, ~510-column filmstrip where an A glides from the far left to the far right across segments, with .|-style separators keeping frames distinct.
🩹 If it’s off: If frames come out in scramble order, files were named without the zero-padding and sorted put frame_10 before frame_2. If each frame row is misaligned, splitlines dropped a trailing newline and the last row padded unevenly.
5.3 Verify the export
✅ Checklist
- ✅
save_framesreturns 17 and writes 17 files namedframe_000.txt…frame_016.txt. - ✅
read_reelreproduces frames[0] and frames[16] exactly from disk. - ✅ Changing a sprite’s velocity changes frame files, proving the reel reflects state, not hardcoded art.
🤔 Socratic Question(s)
- The filmstrip is a timeslice view. What information does it show you about the animation that the frame-by-frame stacking hides, and what motion idiom (rotation, scale) would a 2D row-of-time band never capture?
- The export writes text files you could hand to a non-Python tool. What is the equivalent “open interchange format” in your favorite video tool, and what’s the value of keeping the engine’s output in a format nothing else in your stack needs to translate?
⚠️ Common pitfalls
- Integer division in easing.
t / (t1 - t0)in Python 3 is float, butt // (t1 - t0)or whole-int args silently truncate and freeze your curve. Feed floats to the math helpers. - Round-half-up vs banker’s rounding.
int(x + 0.5)always rounds.5up;round(x)in Python rounds.5to even, so a sprite atx=2.5lands at2withroundand3withint(x+0.5), and float drift makes this non-deterministic in the wild. Pick one and keep it everywhere. - Back-to-back wall clamps. If the bounce check uses
>=/<=on the clamped value every frame, a sprite resting at a wall flips velocity every update and vibrates forever. Require crossing the boundary or check the pre-clamp position. - Off-by-one in frames.
for f in range(17)produces 17 frames throught = 16×dt; to covert=0throught=2.0inclusive you need 17 steps, not 16. Decide whether frames count time steps or wall-clock frames. - Unordered exports. Non-zero-padded filenames sort
frame_10beforeframe_2. Pad to a fixed width (:03d) or the filmstrip scrambles. - Mutating the scene inside play.
scene.stepmust change sprite state in place; recreating the scene per frame loses velocity and bounces forever.
What you just built
A text-first animation engine: easing math, velocity-driven sprites with wall bounces, a fixed-timestep render loop, keyframed motion paths, and a file-based reel. The essential idea is that motion is decided by small, composable functions, clamp guards bounds, lerp travels, smoothstep adds personality, and a class wraps each as state. Frame any motion problem as “which number do I ease, and toward what” and these five pieces answer it, the same shape drives CSS transitions, sprite walks in games, and camera dollys in video.
Run a fuller version without any local setup
examples/animation-engine/ in the course repo is the complete engine as a notebook, sprite bounces, the eased keyframe path, and the filmstrip export, runnable in Colab/Kaggle/Binder. Clone the repo or open it in a Codespace.
Where to go from here
- Add a
Time Warplayer: instead of one globaldt, give each sprite its ownspeedmultiplier so a*drifts lazily while anostreaks. - Model a two-sprite elastic bounce, when sprites collide, exchange velocities and add a
vxwobble for squash-and-stretch. - Extend
Keyframedto hold a per-segment easing function (linear for the first leg, smoothstep for the second) as part of the keyframe data. - Write frames as PPM (P6) images and stitch them into a GIF with a tiny pure-Python writer, or feed the filmstrip into your terminal’s scrollback for a “movie.”
Share your project with the class
Built something you’re proud of? examples/student-projects/ is a gallery of projects other students have submitted, and its README has a full, beginner-friendly walkthrough for adding yours via a pull request, even if you’ve never used git before: forking the repo, making a branch, committing your files, and opening the PR, one step at a time. No prior git experience assumed.
Welcome to writing Python outside the browser. 🎓
Finish every step, then mark this project complete to claim its XP.