Writing Your First Animation

Let's build something together. Over the next few pages you'll write a complete Codimate program from an empty file: one that flips a coin twenty times and shows the running tally rise as each flip lands.

It's a small thing to animate, and that's deliberate. What matters is that by the end you'll have met all four pieces every Codimate program is made from, and you'll have seen the idea the whole library rests on. You never describe movement. You describe moments, and Codimate works out the movement between them.

Setting Up

If you haven't installed Codimate yet, pip install codimate is the whole of it — see the install steps for what ffmpeg and typst are for.

Create a file called coins.py in the repository root, alongside README.md. Run every command in this chapter from that folder, so your video lands in results/ with the others.

The Shape of a Codimate Program

Before we animate anything, let's get the smallest possible program running. This gives us something to build on, and it proves your installation works before we add anything that could obscure a problem.

Type this into coins.py:

import codimate as cm


@cm.trace()
def flip(tally):
    cm.emit("start")


def view(frame):
    scene = cm.Scene()
    scene.text("hello", "nothing yet", size=40,
               at=(cm.width() / 2, cm.height() / 2))
    return scene


cm.explain(trace=flip({"heads": 0, "tails": 0}), view=view).render("results/coins.mp4")

Then run it:

.venv/bin/python coins.py

The program prints nothing and writes results/coins.mp4. Open it and you'll see two seconds of the words "nothing yet" in the middle of a black frame. If that plays, everything is working.

There isn't much on screen, but every part of a Codimate program is already here. flip is the algorithm: ordinary Python, marked with @cm.trace() so Codimate can watch it run. view is the view: it receives one moment and returns one picture. And cm.explain gathers them together and renders.

The call to cm.emit is how you tell Codimate that something worth showing has happened. Your view is asked for a picture once for each one. Right now there is a single moment, so you get a single unchanging picture.

Giving the Algorithm Something to Do

Our program has the right shape but nothing happens in it. Let's write the actual logic.

The important thing here is that you write it the way you always would. There is no Codimate-shaped way to flip a coin. You add one line, calling cm.emit after you change your data, because Codimate takes a snapshot of the result:

import codimate as cm
import random


@cm.trace()
def flip(tally):
    coin = random.Random(4)
    for toss in range(20):
        side = "heads" if coin.random() < 0.5 else "tails"
        tally[side] += 1
        cm.emit("flip", side=side)


def view(frame):
    scene = cm.Scene()
    scene.text("count", frame.state, size=40,
               at=(cm.width() / 2, cm.height() / 2))
    return scene


cm.explain(trace=flip({"heads": 0, "tails": 0}), view=view).render("results/coins.mp4")

Run it again, and this time the numbers count upward on screen.

Two things are worth noticing. The first is that frame.state holds your data as it was at that moment, not as it ended up. Codimate kept a copy each time you called emit, so the view can ask for any of them.

The second is random.Random(4). That's a fixed seed, so you get the same twenty flips every time you render. Without it, every run would produce a different video, and you'd have no way to tell whether a change you made was responsible for a difference you noticed.

Drawing Something Real

Numbers on a screen aren't much of an animation. Let's replace them with two bars, one for heads and one for tails, that grow as the flips land.

This is where you'd expect to start working out coordinates. You don't have to. cm.row divides the canvas for you and hands back a slot for each item:

import codimate as cm
import random

SIDES = ("heads", "tails")


@cm.trace()
def flip(tally):
    coin = random.Random(4)
    for toss in range(20):
        side = "heads" if coin.random() < 0.5 else "tails"
        tally[side] += 1
        cm.emit("flip", side=side)


def view(frame):
    scene = cm.Scene()
    for slot, side in cm.row(SIDES, gap=120, size=190):
        count = frame.state[side]
        bar = scene.group(side, slot)
        bar.rect("box", h=max(count * 22, 1), at=cm.at(bottom=0)).fill("blue")
        bar.text("label", side, size=24, at=cm.at(top=16))
    return scene


cm.explain(trace=flip({"heads": 0, "tails": 0}), view=view).render("results/coins.mp4")

Run it and you'll see two bars rising, each labelled underneath.

Let's walk through what changed. cm.row(SIDES, gap=120, size=190) gives us one slot per side, spaced evenly and centred on the canvas. Sizes come from the canvas unless you say otherwise, and here we do say otherwise, because two bars filling most of the frame look like slabs rather than bars.

scene.group(side, slot) puts a group in that slot. A group is somewhere to draw a thing made of several shapes, and everything drawn on it moves together. Our bar is a rectangle and a label, and because they share a group they can never drift apart.

Inside a group, 0 means the group's own point, which is why the rectangle says bottom=0 to stand on it and the label says top=16 to sit just below. You never convert an edge into a centre yourself.

The call to max(count * 22, 1) keeps each bar at least one pixel tall. A rectangle with no height has nothing to draw, so without it the first flip would make a bar flicker into existence rather than grow.

Finally, and most importantly: scene.group(side, slot) names each bar after the side it counts. Codimate pairs shapes by name between one moment and the next, and whatever changed becomes movement. Here the height changed, so the bars grow. We'll come back to this idea, because it's the one that decides whether anything moves at all.

Reacting to What Just Happened

Our bars grow, but they don't tell you which flip caused which growth. The view can know that, because it receives more than your data. It also receives the event that produced the moment.

Anything you pass to cm.emit arrives as frame.event.data:

import codimate as cm
import random

SIDES = ("heads", "tails")


@cm.trace()
def flip(tally):
    coin = random.Random(4)
    for toss in range(20):
        side = "heads" if coin.random() < 0.5 else "tails"
        tally[side] += 1
        cm.emit("flip", side=side)
    cm.emit("done")


def view(frame):
    scene = cm.Scene()
    landed = frame.event.data.get("side") if frame.event else None

    scene.text("title", "20 coin flips", size=38,
               at=(cm.width() / 2, 90)).fill("grey")

    for slot, side in cm.row(SIDES, gap=120, size=190):
        count = frame.state[side]
        bar = scene.group(side, slot)
        bar.rect("box", h=max(count * 22, 1), at=cm.at(bottom=0)) \
           .fill("orange" if side == landed else "blue")
        bar.text("label", f"{side}  {count}", size=24, at=cm.at(top=16))
    return scene


cm.explain(trace=flip({"heads": 0, "tails": 0}), view=view).render("results/coins.mp4")

Run it. Now the bar that just grew turns orange, then fades back as the next flip lands.

Notice that you didn't ask for a fade. You said the bar is orange in one moment and blue in the next, and Codimate worked out the transition. This is the same mechanism that grew the bars, applied to colour instead of height.

One detail worth remembering: frame.event is None for the opening moment, before anything has happened. That's what the if frame.event is guarding against.

Controlling the Pace

Our animation is complete, but it reads too quickly, and it ends the instant the last flip lands. Let's fix the timing.

In Codimate, duration lives in exactly one place. Your algorithm doesn't know how long anything takes, and neither does your view. Timing holds all of it, keyed by the event names you chose earlier:

import codimate as cm
import random

SIDES = ("heads", "tails")


@cm.trace()
def flip(tally):
    coin = random.Random(4)
    for toss in range(20):
        side = "heads" if coin.random() < 0.5 else "tails"
        tally[side] += 1
        cm.emit("flip", side=side)
    cm.emit("done")


def view(frame):
    scene = cm.Scene()
    landed = frame.event.data.get("side") if frame.event else None
    finished = frame.is_("done")

    scene.text("title", "20 coin flips", size=38,
               at=(cm.width() / 2, 90)).fill("grey")

    for slot, side in cm.row(SIDES, gap=120, size=190):
        count = frame.state[side]
        bar = scene.group(side, slot)
        colour = "orange" if side == landed else "blue"
        bar.rect("box", h=max(count * 22, 1), at=cm.at(bottom=0)) \
           .fill("green" if finished else colour)
        bar.text("label", f"{side}  {count}", size=24, at=cm.at(top=16))
    return scene


cm.explain(
    trace=flip({"heads": 0, "tails": 0}),
    view=view,
    timing=cm.Timing(default=0.35, events={"done": 1.0}, opening=0.8, final_hold=2.0),
).render("results/coins.mp4", fps=60, scale=1.5)

Run it once more. The flips are slower now, both bars turn green when the tally is complete, and the final picture is held long enough to read.

The two arguments to render are new. fps=60, scale=1.5 produces a 1080p60 video. Resolution is a render-time decision rather than something your view knows about, so your coordinates still mean what cm.canvas() says they mean.

Experiments Worth Running

Before moving on, change one thing at a time in coins.py and re-render. Each of these exercises a different one of the four pieces, and each takes a few seconds to see.

Change the data. Make it a hundred flips instead of twenty. The video regenerates from the algorithm; you don't touch the view.

for toss in range(100):

Change the timing. Give the final moment room to breathe.

timing=cm.Timing(default=0.15, events={"done": 2.5}, final_hold=3.0)

Change the view. Colour by which side is winning rather than by which just landed.

color="green" if count == max(frame.state.values()) else "blue"

Then break it deliberately. In the view, name each bar after its position instead of after the side it counts:

for i, (slot, side) in enumerate(cm.row(SIDES, gap=120, size=190)):
    bar = scene.group(i, slot)              # was scene.group(side, slot)

Re-render. Nothing looks different, because with two fixed bars a position and a side identify the same thing. Now swap the order of SIDES as well. The bars jump rather than sliding, because you renamed them: Codimate believes the heads bar left and a different bar arrived in its place.

No error, just a different video. That is the most important thing to understand about Codimate, and it's worth provoking once on purpose.

What You Didn't Have to Write

Look back over what you wrote. There's no keyframe anywhere in it. No duration attached to a shape. No tween, no interpolation, no frame number. You wrote down what happened and what a single moment looks like, and everything between the moments was worked out for you.

That's the whole idea, and it asks one thing of you in return. Because Codimate matches shapes between moments by name, the names you choose are what decide whether something moves or merely changes shape. Our bars were named after the side they count, so they grew in place. Had they been named after their position, something quite different would have happened.

That idea is worth understanding before you write a second animation, and it's the first link below.

Where to Go Next