Skip to content

Examples

Sieve of Eratosthenes

The Sieve of Eratosthenes is a classic algorithm for finding all prime numbers up to a given limit. Starting from 2, each prime's multiples are crossed out repeatedly until only primes remain.

The animation shows numbers 1–100 on a grid. Each discovered prime is highlighted green; its multiples are marked red and then hidden, leaving only the primes at the end.

Output video

with Scene(1280, 720):
    with Group().column(40).align_y(0.6):
        with Group() as g2:
            t = Text().span("Sieve of Eratosthenes").font_size(40).bold()
        with Group() as g3:
            Image("docs/ff_logo.png").height(200)
        wait(0.2)        
        g2.fade_out(0.5)
        g3.hide_right(0.5)
        wait(0.2)

with Scene(1280, 720):
    numbers = []
    with Group().size(1000, 400) as g:
        with Par():
            for i in range(0, 100):
                with Seq():
                    wait(0.01 * i)
                    with Group().xy(50 * (i % 20), (i // 20) * 50) as n:                    
                        Rect().stroke_color("black").color("#ccc").size(40, 40)
                        Text().span(str(i + 1))
                        n.fade_in(0.3)
                numbers.append(n)
        wait(0.2)

        arrow = Path().stroke_color("green").stroke_width(4)
        arrow_start = arrow.move_to().xy(-150, -10)
        arrow_end = arrow.line_to().xy(-150, -50)
        arrow_head = arrow.triangle_arrow("start")

        for step in [2, 3]:
            idx = step - 1
            with Par():
                g.scale(1.90, tr=0.8)
                g.xy(550, 400, tr=0.8)
            if step == 2:
                numbers[0].fade_out(0.5)
                wait(0.5)

            wait(0.5)
            with Par():
                arrow_start.pos(numbers[idx].get_pos(0.5).move(-5, -5), tr=0.5)
                arrow_end.pos(numbers[idx].get_pos(0.5).move(-5, -40), tr=0.5)

            r = numbers[idx].get_child(kind="rect")
            wait(0.3)
            r.color("green", tr=0.4)
            wait(0.2)

            with Group() as m:
                m.pos(numbers[idx].get_pos(0.5))
                p = Path().stroke_color("red").stroke_width(2)
                a = p.move_to()
                b = p.line_to()
                p.move_to().pos(a.get_pos()).move(0, -4)
                p.line_to().pos(a.get_pos()).move(0, 4)
                p.move_to().pos(b.get_pos()).move(0, -4)
                p.line_to().pos(b.get_pos()).move(0, 4)        
                t = Text().pos(numbers[idx].get_child(kind="text").get_pos())
                t.span(str(step)).color("red")            
                m.fade_in(0.5)

            for i in range(3):
                with Par():
                    a.pos(numbers[idx + i * step].get_pos(0.5).move(0, -6), tr=0.5)            
                    b.pos(numbers[idx + (i + 1) * step].get_pos(0.5).move(0, -6), tr=0.5)
                    t.pos(numbers[idx + i * step].get_pos().move(70, -30), tr=0.5)
                #g.pos(numbers[1 + i * 2].get_pos())
                wait(0.2)
                r = numbers[idx + (i + 1) * step].get_child(kind="rect")              
                r.color("red", tr=0.5)
                wait(0.5)

            with Par():
                m.alpha(0, tr=0.5)
                g.scale(1, tr=0.5)
                g.xy_reset(tr=0.5)

            wait(0.5)

            with Par():
                for i in range(idx + (i * step), 100, step):
                    with Seq():
                        wait(i * 0.02)
                        numbers[i].get_child(kind="rect").color("red", 0.3)

        for step in [5, 7, 11]:
            idx = step - 1
            wait(0.3)
            with Par():
                arrow_start.pos(numbers[idx].get_pos(0.5).move(-5, -5), tr=0.5)
                arrow_end.pos(numbers[idx].get_pos(0.5).move(-5, -40), tr=0.5)                                  
            wait(0.2)
            r = numbers[idx].get_child(kind="rect")
            r.color("green", tr=0.4)
            wait(0.2)

            if step == 11:
                break

            with Par():
                for i in range(idx + 3 * step, 100, step):
                    with Seq():
                        wait(i * 0.01)
                        numbers[i].get_child(kind="rect").color("red", tr=0.3)       
        with Par():
            arrow.alpha(0, tr=0.3)
            arrow_head.alpha(0, tr=0.3)            

        PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
        with Par():
            for i, p in enumerate(PRIMES[5:]):
                with Seq():
                    idx = p - 1
                    wait(i * 0.02)
                    numbers[idx].get_child(kind="rect").color("green", tr=0.3)                           

        wait(0.5)
        with Par():
            for i in range(1, 100):            
                if (i + 1) in PRIMES:
                    continue  
                numbers[i].alpha(0, tr=0.5)

        with Par():
            for i, p in enumerate(PRIMES):
                idx = p - 1
                numbers[idx].xy(50 * (i % 20), (i // 20) * 50 + 300, tr=0.5)

        wait(0.5)

    with Group().column(40).align_y(0.2) as g:
        with Group() as g2:
            Text().span("Sieve of Eratosthenes").font_size(40).bold()
        with Group() as g3:
            Image("docs/ff_logo.png").height(200)    
        g.fade_in()
        wait(1)

Walk-through

The sections below explain how the animation is built piece by piece.

Multiple scenes

The animation uses two Scene objects. A Scene is the top-level canvas; it defines the resolution and background. Running multiple Scene calls in sequence produces a multi-scene video where the player transitions from one scene to the next automatically.

with Scene(1280, 720):
    ...   # intro

with Scene(1280, 720):
    ...   # main sieve + outro

The intro screen

with Scene(1280, 720):
    with Group().column(40).align_y(0.6):
        with Group() as g2:
            Text().span("Sieve of Eratosthenes").font_size(40).bold()
        with Group() as g3:
            Image("../docs/ff_logo.png").height(200)
        wait(0.2)
        g2.fade_out(0.5)
        g3.hide_right(0.5)
        wait(0.2)

Group().column(40) creates a vertical layout that stacks its children with 40 px of spacing between them. .align_y(0.6) positions the group 60% of the way down the canvas — just below center.

The with Group() as g2: pattern is the core FairyFlow idiom: every node created inside the with block becomes a child of that group, and the variable g2 is a handle you can use to animate the group as a whole afterward.

Once the children are placed, wait(0.2) moves the global clock forward 0.2 seconds, creating a brief pause where the title and logo are fully visible. .fade_out(0.5) and .hide_right(0.5) then animate the two groups away. Both helpers advance the clock automatically, so the outro takes 0.2 + 0.5 + 0.5 + 0.2 = 1.4 seconds in total. (Note that fade_out and hide_right run sequentially here — use Par to run them simultaneously.)

Building the number grid

numbers = []
with Group().size(1000, 400) as g:
    with Par():
        for i in range(0, 100):
            with Seq():
                wait(0.01 * i)
                with Group().xy(50 * (i % 20), (i // 20) * 50) as n:
                    Rect().stroke_color("black").color("#ccc").size(40, 40)
                    Text().span(str(i + 1))
                    n.fade_in(0.3)
            numbers.append(n)
    wait(0.2)

The outer Group with an explicit size(1000, 400) acts as the stage for the whole sieve animation. Its 100 children are positioned manually with .xy() using simple integer arithmetic:

  • x = 50 * (i % 20) — 20 columns spaced 50 px apart (0, 50, 100 … 950)
  • y = (i // 20) * 50 — a new row every 20 numbers (0, 50, 100, 150, 200)

This gives a 20-column × 5-row grid. Each cell is a Group containing a grey 40 × 40 Rect and a Text label. The numbers list keeps a reference to every cell so the sieve loop can look up any cell by its 0-based index later.

Staggered fade-in with Par and Seq

with Par():
    for i in range(0, 100):
        with Seq():
            wait(0.01 * i)
            ...
            n.fade_in(0.3)

Par starts all 100 children at the same time (t = 0). Each child is a Seq that first calls wait(0.01 * i) to offset its start, then fades the cell in. Cell 0 starts immediately; cell 99 starts 0.99 seconds later — creating a cascade effect. The Par block advances the outer clock to the longest child's end time (≈ 0.99 + 0.3 = 1.3 s), followed by a short wait(0.2) pause.

The arrow indicator

arrow = Path().stroke_color("green").stroke_width(4)
arrow_start = arrow.move_to().xy(-150, -10)
arrow_end   = arrow.line_to().xy(-150, -50)
arrow_head  = arrow.triangle_arrow("start")

Path builds a vector path from a sequence of commands. Each command (move_to(), line_to()) returns a node whose position can be animated independently. triangle_arrow("start") attaches a filled arrowhead at the start endpoint.

The arrow begins at x = −150 — off the left edge of the canvas so it is invisible at first. It will be repositioned later by animating arrow_start and arrow_end to the coordinates of the target cell.

Zooming in and pointing to a prime

for step in [2, 3]:
    idx = step - 1

    with Par():
        g.scale(1.90, tr=0.8)
        g.xy(550, 400, tr=0.8)

Par runs both the scale and position change simultaneously. Passing tr=0.8 on each attribute creates a smooth animated transition over 0.8 seconds and advances the clock to the end of the transition. The grid zooms in and shifts in one fluid move.

    with Par():
        arrow_start.pos(numbers[idx].get_pos(0.5).move(-5, -5), tr=0.5)
        arrow_end.pos(numbers[idx].get_pos(0.5).move(-5, -40), tr=0.5)

get_pos(0.5) returns the center of a cell as a Position object (the argument 0.5 is the horizontal alignment — 0.0 is the left edge, 1.0 is the right edge, 0.5 is the center). Passing that Position to .pos() animates the path endpoint to the cell's center. The .move(-5, -40) call applies a small relative offset, nudging the arrowhead above the cell.

    r = numbers[idx].get_child(kind="rect")
    r.color("green", tr=0.4)

get_child(kind="rect") searches the cell's children for a Rect node and returns it. color("green", tr=0.4) creates a smooth colour transition from grey to green over 0.4 seconds.

The crossing-line animation

with Group() as m:
    m.pos(numbers[idx].get_pos(0.5))
    p = Path().stroke_color("red").stroke_width(2)
    a = p.move_to()
    b = p.line_to()
    p.move_to().pos(a.get_pos()).move(0, -4)
    p.line_to().pos(a.get_pos()).move(0, 4)
    p.move_to().pos(b.get_pos()).move(0, -4)
    p.line_to().pos(b.get_pos()).move(0, 4)
    t = Text().pos(numbers[idx].get_child(kind="text").get_pos())
    t.span(str(step)).color("red")
    m.fade_in(0.5)

This builds a red bracket: a horizontal line from a to b with a short vertical tick at each end. The path has 6 commands in total:

  1. The main line: move_to()a, line_to()b
  2. Left tick: move_to() at a.get_pos() offset by (0, −4), line_to() at a.get_pos() offset by (0, +4)
  3. Right tick: same pattern at b.get_pos()

The key insight is that the tick endpoints are defined relative to a and b using pos(a.get_pos()).move(0, ±4). When a and b are animated to new positions the ticks move with them automatically — you never have to update them separately.

The text label t sits next to the starting cell and shows the prime value in red.

for i in range(3):
    with Par():
        a.pos(numbers[idx + i * step].get_pos(0.5).move(0, -6), tr=0.5)
        b.pos(numbers[idx + (i + 1) * step].get_pos(0.5).move(0, -6), tr=0.5)
        t.pos(numbers[idx + i * step].get_pos().move(70, -30), tr=0.5)
    wait(0.2)
    r = numbers[idx + (i + 1) * step].get_child(kind="rect")
    r.color("red", tr=0.5)
    wait(0.5)

Each iteration advances the bracket one step: a and b slide to the next multiple in parallel (using Par), and then the target cell's rect transitions to red. The bracket hops across the first three multiples of the prime with colour changes in sync.

Marking all remaining multiples

with Par():
    for i in range(idx + (i * step), 100, step):
        with Seq():
            wait(i * 0.02)
            numbers[i].get_child(kind="rect").color("red", tr=0.3)

After the animated demonstration the rest of the multiples are coloured red in a rapid sweep. The outer Par starts all cells at the same time; each inner Seq delays its cell by i * 0.02 seconds and then transitions the rect to red over 0.3 seconds. This is the same staggered-parallel pattern used for the initial cascade fade-in.

Quick pass for 5, 7, 11

for step in [5, 7, 11]:
    idx = step - 1
    # move arrow to the prime, highlight it green
    ...
    with Par():
        for i in range(idx + 3 * step, 100, step):
            with Seq():
                wait(i * 0.01)
                numbers[i].get_child(kind="rect").color("red", tr=0.3)

Primes 5, 7, and 11 get a simpler treatment: the arrow moves to each prime and its background turns green, but there is no zoom-in or crossing-line animation. The multiples loop starts at idx + 3 * step because all smaller multiples of these primes were already marked red during the 2 and 3 passes.

Revealing the primes

wait(0.5)
with Par():
    for i in range(1, 100):
        if (i + 1) in PRIMES:
            continue
        numbers[i].alpha(0, tr=0.5)

All non-prime cells fade out simultaneously in a single Par block. Using tr=0.5 on each alpha(0) call creates a smooth 0.5-second fade, and since they are all inside Par they all start at the same moment.

with Par():
    for i, p in enumerate(PRIMES):
        idx = p - 1
        numbers[idx].xy(50 * (i % 20), (i // 20) * 50 + 300, tr=0.5)

The surviving primes are then repositioned together. Each .xy() call uses tr=0.5 so all cells slide smoothly to their new positions simultaneously inside Par. The new coordinates use the same column/row formula but shifted 300 px down to keep them within the canvas.

The outro

with Group().column(40).align_y(0.2) as g:
    with Group() as g2:
        Text().span("Sieve of Eratosthenes").font_size(40).bold()
    with Group() as g3:
        Image("../docs/ff_logo.png").height(200)
    g.fade_in()
    wait(1)

This outro lives in the same Scene as the grid — it is a sibling group placed at align_y(0.2) (top fifth of the canvas). g.fade_in() is called on the outer column group rather than on g2 and g3 individually, so the title and logo fade in together as one unit. wait(1) extends the scene for one more second so the final frame is not cut off abruptly.