# Make art with code — sixteen tiny programs

A small program can be a piece of art, and changing one is the fastest way
to learn that. This file is sixteen complete programs, each about a hundred
lines of Python, each drawing something alive: a dot field driven by one
formula, interference rings, a strange attractor, digital rain, glowing
metaballs. Nothing here needs a framework, a build step, or an account —
each program is one file with one idea in it.

They run on the [EMF Tildagon](https://tildagon.badge.emfcamp.org/) badge,
and — this is the fun part — in your browser, next to their own source code,
**live**: <https://protogon.codemyriad.io/editor/>

**The point is to change them.** In the editor every plain number in the
code is draggable, colours get a click-to-pick swatch, and edits take effect
as you type — no run button, no reload. If an edit breaks, the badge keeps
running the last working version while the error points at your line, so
there is nothing to be afraid of. Each program tells you where to start:

- a `HOW IT WORKS` header that explains the one idea in plain words,
- a `tweak me` block of knobs with suggested values to try,
- a `try this` footer of small experiments that actually work.

None of these ideas are new, and that's the other lesson: the header of each
program links its prior art — arcade games, demoscene effects, screensavers,
a Scientific American column from 1986. People have been making art with
tiny programs for half a century. These are yours to continue: take one,
drag its numbers until it feels like yours, rename it, show someone.

*The demos began in the [protogon](https://github.com/codemyriad/protogon)
project. Their maintained source, host simulator and browser editor now live
in the [`livecoding-web`](https://github.com/codemyriad/badge-2024-software/tree/main/livecoding-web)
directory of the Code Myriad Tildagon fork. The editor runs the unmodified
badge firmware — scheduler, event bus and ctx renderer — under
Python-in-WebAssembly, so what you see in the browser is what the badge does.*

## The programs

1. **[Tixy dot field](#1--tixy)** — One tiny formula animates a 16x16 grid: its sign chooses warm or cool, and its magnitude chooses each dot's size
2. **[Moiré rings](#2--moire)** — Two families of fine rings drift through one another; the interference pattern does the drawing
3. **[Qix tracer](#3--qix)** — Bouncing points weave bright, fading afterimages
4. **[Tilt-steered starfield](#4--starfield)** — Stars rush towards you as the badge steers their drift; the LED ring points the way you lean
5. **[Polar tunnel](#5--tunnel)** — A stack of concentric rings breathes and drifts until those flat circles become a 3D tunnel rushing past you
6. **[Pocket kaleidoscope](#6--kaleidoscope)** — Rotate and mirror one tiny moving motif around the centre, and an instant snowflake appears
7. **[Hopalong attractor](#7--hopalong)** — One point follows a simple rule; hundreds of hops reveal a slowly turning cloud of swirls and petals
8. **[Digital rain](#8--matrixrain)** — Luminous glyphs stream down the round display, each column trailing a bright head and a fading tail
9. **[Plasma tiles](#9--plasma)** — Four drifting sine waves wash colour across a tile grid: classic demoscene plasma without a per-pixel framebuffer
10. **[Two rings, one clock](#10--ledring)** — Twelve dots on the glass mirror the twelve bezel LEDs, and a shared clock keeps both rings moving together
11. **[Growing pipes](#11--pipes)** — A bright head wanders across a hidden grid, laying colourful pipe behind it like the classic screensaver
12. **[Cellular playground](#12--cellular)** — Three tiny rules turn random noise into Life, Brian's Brain, or a cyclic chase of colour
13. **[Drifting ribbons](#13--ribbons)** — Wavy anchor points are threaded into smooth curves, then phase-shifted copies weave themselves into a living braid
14. **[Orbiting metaballs](#14--metaballs)** — Glowing blobs circle the centre and seem to melt into one another whenever their halos overlap
15. **[Multiplication circle](#15--timescope)** — As one number slowly changes, straight chords fold themselves into cardioids, flowers and delicate knots
16. **[Part of this app is C](#16--fastcode)** — native/fastcode.c compiles to a MicroPython native module (fastcode.mpy) the badge imports at runtime -- no firmware rebuild, no reflashing. The live preview runs that SAME C, compiled to WebAssembly, so what you see as you type is your real C -- 42 is computed in C both here and on the badge

---

## 1 · tixy

**Tixy dot field** · [play with it live](https://protogon.codemyriad.io/editor/#tixy) · [see also](https://tixy.land)

```python
# tixy (1) -- Tixy dot field. One tiny formula animates a 16x16 grid: its sign
# chooses warm or cool, and its magnitude chooses each dot's size.
#
# HOW IT WORKS
#   Every frame, each dot asks one little formula, "What should I look like?"
#   The formula receives the time t, dot number i, column x and row y, then
#   answers with a number:
#       positive -> warm colour      negative -> cool colour
#       magnitude below VISIBLE -> hidden    magnitude 1 or more -> biggest dot
#   DOT_FILL sets that biggest size. That is the whole machine: five formulas
#   are waiting below, and there is room to write your own.
#
# Credits:   tixy.land by Martin Kleppe (@aemkei) -- https://tixy.land
#
# BUTTONS   LEFT/RIGHT previous/next formula - DOWN/UP slower/faster - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range; a/b/c select values in a tuple.
GRID     = 16                  # dots per side; 8 is chunky, 24 works hard 4<n<24
SPACING  = 13.0                # px between dot centres; bigger feels airier 6<n<24
DOT_MIN  = 1.5                 # radius of the smallest visible dot 0<n<6
DOT_FILL = 0.46                # largest-dot radius as a share of spacing 0.1<n<0.5
VISIBLE  = 0.08                # formula values below this stay hidden 0<n<0.5
WARM     = (1.0, 0.55, 0.15)   # colour for positive values (amber) -- tap the swatch
COOL     = (0.2, 0.55, 1.0)    # colour for negative values (sky blue)

# ----------------------------- the formulas ----------------------------------
# A formula receives (t, i, x, y): t is time in seconds, x/y are the column and
# row (0..GRID-1), and i is the dot number. Values outside -1..1 are clipped
# when drawn. Edit one, break one, then add one of your own.

def waves(t, i, x, y):
    # Two ripples slide across each other, one along each axis.
    return math.sin(t + x * 0.6) + math.cos(t * 0.9 + y * 0.6)

def spin(t, i, x, y):
    # Every dot pulses a moment after its neighbour, making a rolling shimmer.
    return math.sin(t * 2 + i * 0.15)

def ripple(t, i, x, y):
    # Rings spread from one point, like ripples from a stone dropped in water.
    centre = (GRID - 1) / 2.0
    d = ((x - centre) ** 2 + (y - centre) ** 2) ** 0.5
    return math.sin(d * 0.9 - t * 2.2)

def plaid(t, i, x, y):
    # Horizontal waves multiplied by vertical waves become woven cloth.
    return math.sin(x * 0.7 - t) * math.cos(y * 0.7 - t * 1.3)

def bloom(t, i, x, y):
    # A disc opens and closes with a slow heartbeat.
    centre = (GRID - 1) / 2.0
    d = ((x - centre) ** 2 + (y - centre) ** 2) ** 0.5
    return 3.2 - d + math.sin(t) * 2.4

# --------------------------- pick the live one -------------------------------
# Click a name to run it: the badge switches instantly, without restarting.
# On a real badge, LEFT/RIGHT cycle through the choices. Each choice is one
# assignment below; uncomment the one you want, or edit its formula above.
LIVE = waves      #: waves
# LIVE = spin     #: spin
# LIVE = ripple   #: ripple
# LIVE = plaid    #: plaid
# LIVE = bloom    #: bloom

ORDER = (waves, spin, ripple, plaid, bloom)   # what LEFT/RIGHT cycle on a badge


class Tixy(app.App):
    """Keep time, ask the live formula, and turn its answers into dots."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False            # Have we taken the screen yet?
        self.t = 0.0               # seconds since start, scaled by speed
        self.formula = LIVE        # The live formula selected above.
        self.speed = 1.0
        # Work out every screen position once, rather than once per frame.
        origin = -(GRID - 1) * SPACING / 2.0   # Centre the grid.
        self.cells = [(origin + x * SPACING, origin + y * SPACING, x, y)
                      for y in range(GRID) for x in range(GRID)]

    # Carry time and speed across live edits, but not the formula. That lets a
    # click on a new formula swap it in immediately.
    __live_state__ = ("t", "speed")

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += (delta / 1000.0) * self.speed
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            here = ORDER.index(self.formula) if self.formula in ORDER else 0
            self.formula = ORDER[(here + step) % len(ORDER)]
        if b.get(BUTTON_TYPES["UP"]):
            b.clear()
            self.speed = min(4.0, self.speed * 1.5)
        if b.get(BUTTON_TYPES["DOWN"]):
            b.clear()
            self.speed = max(0.15, self.speed / 1.5)
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        formula = self.formula
        dot_max = max(DOT_MIN, SPACING * DOT_FILL)  # Largest dots never shrink.
        for (px, py, x, y) in self.cells:
            value = formula(self.t, y * GRID + x, x, y)
            size = abs(value)
            if size < VISIBLE:            # Too small to see, so skip it.
                continue
            if size > 1.0:
                size = 1.0
            colour = WARM if value > 0 else COOL
            ctx.rgba(colour[0], colour[1], colour[2], 0.35 + 0.65 * size)
            ctx.arc(px, py, DOT_MIN + size * (dot_max - DOT_MIN),
                    0, 6.2832, True).fill()
        ctx.rgb(0.7, 0.7, 0.7)
        ctx.font_size = 16
        ctx.text_align = ctx.CENTER
        ctx.move_to(0, 112).text(formula.__name__)
        ctx.restore()


__app_export__ = Tixy

# ------------------------------ try this --------------------------------------
# - Lower VISIBLE to 0.01 for a mist of tiny answers, or raise it to 0.3 so only
#   each formula's strongest beats make it onto the screen.
# - In waves(), change math.cos to math.sin. Then try math.tan and enjoy the chaos.
# - Add your own formula:  def stripes(t, i, x, y): return math.sin(x - t * 3)
#   Give it a "# LIVE = stripes  #: stripes" picker line, then click its name.
```

---

## 2 · moire

**Moiré rings** · [play with it live](https://protogon.codemyriad.io/editor/#moire) · [see also](https://en.wikipedia.org/wiki/Moir%C3%A9_pattern)

```python
# moire (2) -- Moiré rings. Two families of fine rings drift through one
# another; the interference pattern does the drawing.
#
# HOW IT WORKS
#   First draw one family of thin rings, then reflect every centre to make a
#   second family. LOOP_Y shapes their drifting path. In polygon mode, SPIN turns
#   the twins in opposite directions. Each family is simple alone; together,
#   their lines repeatedly align and slip apart. Your eye gathers those
#   near-matches into broad curves that nobody drew. The same ghostly moiré
#   pattern appears in overlapping fences and striped shirts that shimmer on
#   camera.
#
# See also:  moiré patterns -- https://en.wikipedia.org/wiki/Moir%C3%A9_pattern
#
# BUTTONS   LEFT/RIGHT swap circles/polygons - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
RINGS    = 11                  # rings per family; try 6 for space or 16 for density 4<n<24
RING_GAP = 8.0                 # gap between radii in px; try 5.0 for tight lines 2<n<16
INNER    = 14                  # innermost radius; try 30 for a wide empty centre 0<n<40
DRIFT    = 30.0                # off-centre drift in px; try 70.0 for wide sweeps 0<n<100
SPEED    = 1.0                 # drift speed; try 3.0 for a dizzy spell 0<n<5
STAGGER  = 0.30                # delay between rings; try 0.0 for rigid or 0.9 for fluid 0<n<1
LOOP_Y   = 1.3                 # vertical drift rhythm; 1.0 traces a simple circle 0<n<3
SIDES    = 6                   # corners in polygon mode; try 3 for triangles 3<n<12
SPIN     = 0.4                 # polygon turn rate; negative reverses direction -2<n<2
LINE_W   = 1.6                 # line width in px; try 0.8 for fine or 3.0 for bold 0<n<6
CYAN     = (0.15, 0.75, 1.0)   # first family's colour -- tap the swatch
PINK     = (1.0, 0.25, 0.55)   # second family's colour (the mirror twin)

# ------------------------------ ring shapes ----------------------------------

def circle_ring(ctx, cx, cy, radius):
    # One fine circle outline.
    ctx.arc(cx, cy, radius, 0, 6.2832, True).stroke()


def polygon_ring(ctx, cx, cy, radius, spin):
    # Walk SIDES corners around the centre and join the dots.
    step = 6.2832 / SIDES
    ctx.begin_path()
    for corner in range(SIDES + 1):
        angle = spin + corner * step
        x = cx + radius * math.cos(angle)
        y = cy + radius * math.sin(angle)
        if corner == 0:
            ctx.move_to(x, y)
        else:
            ctx.line_to(x, y)
    ctx.stroke()


class Moire(app.App):
    """Drift two mirrored ring families and switch their shared shape."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # Have we taken the screen yet?
        self.t = 0.0           # The shared clock behind every drift.
        self.hexed = False     # False = circles, True = polygons

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += (delta / 1500.0) * SPEED
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.hexed = not self.hexed
        return True

    def ring(self, ctx, cx, cy, radius, spin):
        # Draw one ring in whichever shape the buttons have selected.
        if self.hexed:
            polygon_ring(ctx, cx, cy, radius, spin)
        else:
            circle_ring(ctx, cx, cy, radius)

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        ctx.line_width = LINE_W           # Fine lines reveal the moire pattern best.
        t = self.t
        spin = t * SPIN                   # How fast the polygons rotate.
        for k in range(RINGS):
            # Each ring's centre follows a looping path. STAGGER makes ring k
            # trail ring k-1, stretching and folding the family as it moves.
            wobble = t + k * STAGGER
            ox = math.sin(wobble) * DRIFT
            oy = math.cos(wobble * LOOP_Y) * DRIFT
            radius = INNER + k * RING_GAP         # This ring's radius.
            # Family one is cool, with warmer outer rings hinting at depth.
            ctx.rgba(min(1.0, CYAN[0] + 0.05 * k), CYAN[1], CYAN[2], 0.42)
            self.ring(ctx, ox, oy, radius, spin)
            # Family two is warm, perfectly mirrored, and spins backwards.
            ctx.rgba(PINK[0], min(1.0, PINK[1] + 0.04 * k), PINK[2], 0.40)
            self.ring(ctx, -ox, -oy, radius, -spin)
        ctx.restore()


__app_export__ = Moire

# ------------------------------ try this --------------------------------------
# - Set STAGGER to 0.0. Each family snaps into a rigid bullseye, leaving two
#   targets to orbit instead of one billowing interference cloud.
# - Set LOOP_Y to 1.0. Every centre now travels in a perfect circle, so the
#   pattern stops folding and simply orbits.
# - Press RIGHT for polygons, then lower SIDES to 3 for a drifting triangular
#   moiré. Four sides make diamonds; twelve are almost circles again.
```

---

## 3 · qix

**Qix tracer** · [play with it live](https://protogon.codemyriad.io/editor/#qix) · [see also](https://en.wikipedia.org/wiki/Qix)

```python
# qix (3) -- Qix tracer. Bouncing points weave bright, fading afterimages.
#
# HOW IT WORKS
#   A handful of points fly around inside an invisible box, bouncing off its
#   walls. Every frame, we join them into a closed shape and save a copy. The
#   latest TRAIL copies are drawn from oldest to newest: early ones faint and
#   fine, recent ones bright and bold. FADE_CURVE decides how quickly old shapes
#   disappear. There is no blur trick, just memories politely fading away. The
#   ring of 12 LEDs breathes in the current colour.
#
# See also:  Qix, the 1981 Taito arcade game -- https://en.wikipedia.org/wiki/Qix
#
# BUTTONS   LEFT/RIGHT previous/next palette - CONFIRM cycles 2->3->4 points - CANCEL exits
import app
import math
import random
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent
from system.patterndisplay.events import PatternDisable, PatternEnable
from tildagonos import tildagonos

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
TRAIL       = 42                # shapes remembered; 12 is crisp, 80 flows 0<n<100
BOX         = 106               # bounce-box half-width; 60 ties a knot 20<n<120
SPEED_MIN   = 70.0              # slowest new point in px/s; 30.0 drifts 0<n<200
SPEED_MAX   = 110.0             # fastest new point in px/s; 300.0 is frantic 0<n<400
FADE_CURVE  = 2.0               # trail fade; 1.0 is soft, 4.0 is a spark 0.5<n<5
TRAIL_ALPHA = 0.9               # brightness of the newest trail shape 0<n<1
LINE_MIN    = 1.0               # width of the oldest trail line in px 0<n<5
LINE_MAX    = 3.0               # width of the newest trail line in px 0<n<8
HEAD_SIZE   = 3.0               # radius of the bright leading points in px 0<n<10
RAINBOW     = 0.15              # hue turns per second; 0.5 races 0<n<1
LED_WAVE    = 2.0               # LED wave speed; 0.5 is gentle, 6.0 busy 0<n<10
EMBER       = (1.0, 0.4, 0.1)   # second palette: glowing coals -- tap the swatch
ICE         = (0.3, 0.9, 1.0)   # third palette: cold blue


def hue(h):
    # Walk six equal segments of the colour wheel:
    # red -> yellow -> green -> cyan -> blue -> magenta -> red.
    h = (h - int(h)) * 6.0     # which of the six rainbow segments we are in
    i = int(h)
    f = h - i                  # how far into that segment, 0..1
    return ((1.0, f, 0.0), (1.0 - f, 1.0, 0.0), (0.0, 1.0, f),
            (0.0, 1.0 - f, 1.0), (f, 0.0, 1.0), (1.0, 0.0, 1.0 - f))[i % 6]


class Qix(app.App):
    """Bounce a handful of points and let their connected shapes fade away."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False          # Have we taken the screen and LEDs yet?
        self.t = 0.0             # Seconds since launch.
        self.npts = 3            # Number of bouncing points (2..4).
        self.palette = 0         # 0 rainbow, 1 ember, 2 ice
        self.respawn()

    # Preserve button choices and time; trails themselves restart after an edit.
    __live_state__ = ("fg", "t", "npts", "palette")

    def respawn(self):
        # Toss self.npts fresh points into the box, each flying its own way.
        self.pts = []
        for _ in range(self.npts):
            ang = random.uniform(0, 6.28)
            speed = random.uniform(SPEED_MIN, SPEED_MAX)
            self.pts.append([random.uniform(-BOX, BOX),
                             random.uniform(-BOX, BOX),
                             math.cos(ang) * speed, math.sin(ang) * speed])
        self.trail = []          # Remembered shapes; each is a list of (x, y).

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())      # Borrow the LED ring.
            self.fg = True
        # Live migration restores npts after __init__; respawn to match it.
        if len(self.pts) != self.npts:
            self.respawn()
        dt = delta / 1000.0
        self.t += dt
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            eventbus.emit(PatternEnable())       # Hand the ring back.
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["CONFIRM"]):
            b.clear()
            self.npts = 2 + (self.npts - 1) % 3  # 2 -> 3 -> 4 -> back to 2
            self.respawn()
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            self.palette = (self.palette + step) % 3
        for p in self.pts:                       # p = [x, y, vx, vy]
            p[0] += p[2] * dt
            p[1] += p[3] * dt
            if p[0] < -BOX or p[0] > BOX:        # Hit a side wall: bounce.
                p[2], p[0] = -p[2], max(-BOX, min(BOX, p[0]))
            if p[1] < -BOX or p[1] > BOX:        # Hit the floor or ceiling.
                p[3], p[1] = -p[3], max(-BOX, min(BOX, p[1]))
        # Remember this frame's shape, then forget the oldest one.
        self.trail.append([(p[0], p[1]) for p in self.pts])
        if len(self.trail) > TRAIL:
            self.trail.pop(0)
        self.light_ring()
        return True

    def head_colour(self):
        # Palette 0 drifts around the rainbow; the others hold one mood.
        if self.palette == 0:
            return hue((self.t * RAINBOW) % 1.0)
        if self.palette == 1:
            return EMBER
        return ICE

    def light_ring(self):
        # The LEDs breathe in the head colour as a wave circles the ring.
        r, g, bl = self.head_colour()
        base = self.t * LED_WAVE                 # wave speed
        for i in range(1, 13):
            glow = 0.35 + 0.65 * (0.5 + 0.5 * math.sin(base + i * 0.52))
            tildagonos.leds[i] = (int(r * 255 * glow), int(g * 255 * glow),
                                  int(bl * 255 * glow))
        tildagonos.leds.write()

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        n = len(self.trail)
        hr, hg, hb = self.head_colour()
        for idx in range(n):
            shape = self.trail[idx]
            age = (idx + 1) / n              # Near 0 = oldest; 1 = right now.
            ctx.line_width = LINE_MIN + (LINE_MAX - LINE_MIN) * age
            ctx.rgba(hr, hg, hb, (age ** FADE_CURVE) * TRAIL_ALPHA)
            hx, hy = shape[0]
            ctx.move_to(hx, hy)
            for (x, y) in shape[1:]:
                ctx.line_to(x, y)
            ctx.line_to(hx, hy)              # Return home to close the loop.
            ctx.stroke()
        ctx.rgb(1, 1, 1)                     # Bright white heads mark "now".
        for (x, y) in self.trail[-1] if self.trail else []:
            ctx.arc(x, y, HEAD_SIZE, 0, 6.2832, True).fill()
        ctx.restore()


__app_export__ = Qix

# ------------------------------ try this --------------------------------------
# - Raise TRAIL to 80 and SPEED_MAX to 300.0. Long ribbons whip around the box.
# - Squeeze BOX down to 40 and the whole dance folds into a knot at mid-screen.
# - Lower FADE_CURVE to 1.0 for a soft, even fade. Raise it to 4.0 and most of
#   the trail vanishes, leaving a bright spark dragging a fine thread.
```

---

## 4 · starfield

**Tilt-steered starfield** · [play with it live](https://protogon.codemyriad.io/editor/#starfield) · [see also](https://en.wikipedia.org/wiki/3D_projection#Perspective_projection)

```python
# starfield (4) -- Tilt-steered starfield. Stars rush towards you as the badge
# steers their drift; the LED ring points the way you lean.
#
# HOW IT WORKS
#   A star is three numbers: x, y and a depth z running from 1 (far away)
#   towards 0 (right under your nose). Perspective follows an everyday rule:
#   whatever is twice as far away looks half as big, and sits half as far
#   from the centre of your view. draw() applies that rule by dividing x and
#   y by the depth z. While z is large a star huddles near the middle; as z
#   shrinks each frame, the same divide lands it further and further out --
#   growing and brightening on the way (GROW) -- until it whooshes past the
#   glass and restarts at the back. SPREAD sets how widely new stars scatter,
#   ACCEL adds a last-second lunge, and tilting the badge drags the whole
#   field sideways so you can bank through the stars.
#
# See also:  why dividing by distance is exactly perspective (the geometry) --
#            https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
#
# BUTTONS   tilt steers (W/A/S/D in the sim) - DOWN/UP slower/faster - CANCEL exits
import app
import math
import random
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent
from system.patterndisplay.events import PatternDisable, PatternEnable
from tildagonos import tildagonos

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
STARS     = 70              # star count; try 150 (blizzard) or 20 (calm) 0<n<200
SPREAD    = 1.0             # width of the launch cloud; 0.3 is a tight stream 0.1<n<2
FOV       = 90.0            # camera zoom; 40.0 snow globe, 160.0 warp tunnel 20<n<200
TILT_GAIN = 0.6             # steering strength; try 2.0 (twitchy) 0<n<3
SMOOTH    = 0.15            # steering response; 0.02 oil tanker, 0.5 instant 0<n<1
GROW      = 3.0             # how large nearby stars become; try 8.0 0<n<12
STAR_CORE = 0.5             # radius of even the faintest visible star in px 0<n<4
BLUE_TINT = 0.15            # icy highlight; 0.0 white, 0.6 deep space 0<n<1
WARP      = 0.65            # starting speed; 2.0 is hyperspace 0<n<3
ACCEL     = 0.0             # near-star acceleration; 0.0 cruises, 2.0 lunges 0<n<4
STAR      = (1.0, 1.0, 1.0) # base star tint (r,g,b); try (1.0, 0.9, 0.7) for warmth


def read_tilt():
    # The accelerometer reports the pull of gravity in m/s^2. Readings can
    # hiccup on real hardware, and the simulator may omit parts of the API,
    # so either problem falls back to "held flat". A lean of 6 m/s^2 counts
    # as a full-strength push.
    try:
        import imu
        ax, ay, _ = imu.acc_read()
    except Exception:
        ax, ay = 0.0, 0.0
    return (max(-1.0, min(1.0, ax / 6.0)),
            max(-1.0, min(1.0, ay / 6.0)))


class Starfield(app.App):
    """Fly a field of [x, y, depth] stars and steer it by tilting."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # Have we taken the screen yet?
        self.warp = WARP       # Flight speed; UP/DOWN adjust it while running.
        self.tiltx = 0.0       # smoothed lean, -1..1
        self.tilty = 0.0
        # Scatter the stars, each already partway through its journey.
        self.stars = [[random.uniform(-SPREAD, SPREAD),
                       random.uniform(-SPREAD, SPREAD),
                       random.uniform(0.05, 1.0)] for _ in range(STARS)]

    # Keep the feel of the current lean, but let an edited WARP take effect.
    __live_state__ = ("tiltx", "tilty")

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())   # This sample drives the LED ring itself.
            self.fg = True
        dt = delta / 1000.0
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            eventbus.emit(PatternEnable())    # Hand the ring back to the system.
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["UP"]):
            b.clear()
            self.warp = min(2.0, self.warp * 1.4)
        if b.get(BUTTON_TYPES["DOWN"]):
            b.clear()
            self.warp = max(0.15, self.warp / 1.4)
        self.steer()
        self.fly(dt)
        self.light_leds()
        return True

    def steer(self):
        # Ease towards each tilt reading instead of snapping to it, so steering
        # feels like banking a spaceship rather than flicking a switch.
        tx, ty = read_tilt()
        self.tiltx += (tx - self.tiltx) * SMOOTH
        self.tilty += (ty - self.tilty) * SMOOTH

    def fly(self, dt):
        for star in self.stars:
            rush = 1.0 + ACCEL * (1.0 - star[2])
            star[2] -= self.warp * dt * rush        # Rush towards the camera.
            star[0] += self.tiltx * dt * TILT_GAIN  # The lean drags the field.
            star[1] += self.tilty * dt * TILT_GAIN
            # Past the camera or far off to one side? Send it to the back.
            if star[2] <= 0.05 or abs(star[0]) > 2 or abs(star[1]) > 2:
                star[0] = random.uniform(-SPREAD, SPREAD)
                star[1] = random.uniform(-SPREAD, SPREAD)
                star[2] = 1.0                       # Back on the far plane.

    def light_leds(self):
        # Point the ring into the lean: one bright LED at its angle, with a dim
        # neighbour on either side.
        for i in range(1, 13):
            tildagonos.leds[i] = (0, 0, 0)
        lean = (self.tiltx ** 2 + self.tilty ** 2) ** 0.5
        if lean > 0.08:                             # Ignore tiny wobbles.
            angle = math.atan2(self.tilty, self.tiltx)
            idx = int(round((angle + math.pi / 2) / 6.28318 * 12)) % 12
            glow = int(min(1.0, lean) * 255)
            dim = glow // 3
            tildagonos.leds[idx + 1] = (glow, glow, glow)
            tildagonos.leds[(idx + 1) % 12 + 1] = (dim, dim, dim)
            tildagonos.leds[(idx + 11) % 12 + 1] = (dim, dim, dim)
        tildagonos.leds.write()

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        for star in self.stars:
            z = star[2]
            # The header's perspective rule: divide the sideways offsets by
            # distance. FOV is the zoom -- pixels per unit of offset at z = 1.
            px = star[0] / z * FOV
            py = star[1] / z * FOV
            if px * px + py * py > 118.0 * 118.0:   # Outside the round glass.
                continue
            bright = 1.0 - z            # Nearer means brighter.
            if bright < 0.05:           # Newborn stars are still too dim to see.
                continue
            ctx.rgba(bright * STAR[0], bright * STAR[1],
                     min(1.0, bright + BLUE_TINT) * STAR[2], 1.0)
            ctx.arc(px, py, STAR_CORE + bright * GROW, 0, 6.2832, True).fill()
        ctx.restore()


__app_export__ = Starfield

# ------------------------------ try this --------------------------------------
# - Pull FOV down to 40.0 and the field gathers into a snow globe. Push it past
#   150.0 and you are staring into a warp tunnel.
# - Set SMOOTH to 0.02 and tilt. The steering keeps gliding after you level out,
#   like a heavy ship taking its time to answer the helm.
# - Raise ACCEL to 2.0. Stars gather speed as they approach, turning a steady
#   cruise into a proper hyperspace jump.
```

---

## 5 · tunnel

**Polar tunnel** · [play with it live](https://protogon.codemyriad.io/editor/#tunnel) · [see also](https://en.wikipedia.org/wiki/Parallax)

```python
# tunnel (5) -- Polar tunnel. A stack of concentric rings breathes and drifts
# until those flat circles become a 3D tunnel rushing past you.
#
# HOW IT WORKS
#   Every frame, each ring asks one question: "How bright am I right now?"
#   A sine wave supplies the answer as it travels outwards through the stack.
#   CONTRAST shapes the resulting bright band; LINE_GLOW makes its rings swell.
#   The band rolls towards you and creates the sense of forward motion. Meanwhile,
#   WANDER_X and WANDER_Y steer the mouth around a looping path. Small, distant
#   rings take the full drift while large, nearby rings barely move. Your brain
#   sees a tunnel.
#
# See also:  near and far things shifting by different amounts is the depth
#            cue this code fakes -- https://en.wikipedia.org/wiki/Parallax
#
# BUTTONS   LEFT/RIGHT previous/next colour scheme - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
RINGS     = 22     # rings in the stack; 10 is sparse, 34 is dense 6<n<40
INNER     = 6.0    # smallest-ring radius; bigger opens the tunnel mouth 2<n<24
RING_GAP  = 5.2    # px from one ring to the next 2<n<10
BREATH    = 3.0    # bright-band speed; negative rolls inward -6<n<8
RIPPLE    = 0.55   # wave crowding; bigger squeezes more bands in 0<n<2
WANDER    = 12.0   # mouth drift; 30 gets seasick 0<n<40
WANDER_X  = 0.7    # horizontal drift rhythm 0<n<2
WANDER_Y  = 0.9    # vertical rhythm; match WANDER_X for a clean circle 0<n<2
CONTRAST  = 2.0    # bright-band shape; 1.0 is soft, 4.0 is sharp 0.5<n<5
LINE_MIN  = 2.0    # width of the darkest rings in px 0<n<8
LINE_GLOW = 4.0    # extra width added at a wave crest in px 0<n<10

# --------------------------- pick the colours --------------------------------
# Click a scheme to switch it live; on a real badge, LEFT/RIGHT cycle through
# them. A tint controls how much of each colour channel a ring keeps. Each value
# is between 0 and 1, so its swatch opens a colour picker. BOOST lets a channel
# reach full brightness before the wave peaks -- the secret behind fire's glow.
TINT = (0.8, 0.85, 1.0)     #: moonlight
# TINT = (1.0, 0.45, 0.1)   #: fire
# TINT = (0.2, 0.7, 1.0)    #: ice
# TINT = (1.0, 0.25, 1.0)   #: magenta
BOOST = 1.35      # colour punch; 1.0 stays unsaturated, higher values flare 0<n<3

PALETTE = ((0.8, 0.85, 1.0), (1.0, 0.45, 0.1),
           (0.2, 0.7, 1.0), (1.0, 0.25, 1.0))   # what LEFT/RIGHT cycle

TAU = 6.28318


def tinted(glow, tint):
    # Multiply brightness by the scheme tint and BOOST, then cap it at full.
    g = glow * BOOST
    return (min(1.0, g * tint[0]),
            min(1.0, g * tint[1]),
            min(1.0, g * tint[2]))


class Tunnel(app.App):
    """Keep time, cycle colour schemes, and draw the breathing ring stack."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # Have we taken the screen yet?
        self.t = 0.0           # seconds since start
        self.tint = TINT       # The live tint selected above.

    # Carry time across live edits, but not the tint, so a click swaps schemes.
    __live_state__ = ("t",)

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += delta / 1000.0
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            here = PALETTE.index(self.tint) if self.tint in PALETTE else 0
            self.tint = PALETTE[(here + step) % len(PALETTE)]
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        t = self.t
        # Two waves at slightly different speeds send the tunnel mouth around a
        # slow loop whose path never quite repeats.
        ox = math.sin(t * WANDER_X) * WANDER
        oy = math.cos(t * WANDER_Y) * WANDER
        tint = self.tint
        for k in range(RINGS):
            # Find this ring's place on the wave rolling through the stack.
            glow = 0.5 + 0.5 * math.sin(k * RIPPLE - t * BREATH)
            glow = glow ** CONTRAST       # Higher contrast deepens the shadows.
            cr, cg, cb = tinted(glow, tint)
            ctx.line_width = LINE_MIN + glow * LINE_GLOW
            # For parallax, far (small) rings take the full wobble while nearby
            # rings take almost none. k/RINGS tells us how near each ring is.
            depth = k / RINGS
            ctx.rgba(cr, cg, cb, 0.5 + 0.5 * glow)  # Dim rings become translucent.
            ctx.arc(ox * (1 - depth), oy * (1 - depth),
                    INNER + k * RING_GAP, 0, TAU, True).stroke()
        ctx.restore()


__app_export__ = Tunnel

# ------------------------------ try this --------------------------------------
# - Set WANDER to 0.0 to stop the drift and leave a perfect set of breathing rings.
# - Make BREATH negative (-3.0). The wave turns inwards and the tunnel swallows
#   you instead of spitting you out.
# - Lower CONTRAST to 1.0 for a soft rolling glow, or raise it to 4.0 for a thin
#   pulse of light. LINE_GLOW decides how dramatically that pulse swells.
```

---

## 6 · kaleidoscope

**Pocket kaleidoscope** · [play with it live](https://protogon.codemyriad.io/editor/#kaleidoscope) · [see also](https://en.wikipedia.org/wiki/Kaleidoscope)

```python
# kaleidoscope (6) -- Pocket kaleidoscope. Rotate and mirror one tiny moving
# motif around the centre, and an instant snowflake appears.
#
# HOW IT WORKS
#   A real kaleidoscope needs only a pinch of coloured pieces; its mirrors do
#   everything else. We use the same trick. Each frame, we draw one small motif
#   (a spoke and two drifting dots), stamp it around the centre, then stamp a
#   flipped twin in every wedge. The motif is simple; symmetry makes it bloom.
#   CONFIRM throws fresh dice for the motif's orbit, spin, size and reach; the
#   MIN/MAX knobs below fence those dice in, so a drag reshapes the flake
#   without rerolling it. The twelve bezel LEDs answer the glass with the same
#   rainbow, turning in step with the flake.
#
# See also:  kaleidoscopes -- https://en.wikipedia.org/wiki/Kaleidoscope
#            play with the maths of mirrors (its name is dihedral symmetry) --
#            https://mathigon.org/course/transformations
#
# BUTTONS   LEFT/RIGHT previous/next symmetry - CONFIRM rerolls the dice -
#           CANCEL exits
import app
import math
import random
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent
from system.patterndisplay.events import PatternDisable, PatternEnable
from tildagonos import tildagonos

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range; a/b/c select values in a tuple.
SYMMETRIES = (6, 8, 12)   # rotations; LEFT/RIGHT pick one, the label names it 2<a<24 2<b<24 2<c<24
WHIRL      = 0.2          # whole-flake spin; try 1.0 or -0.4 -1<n<2
HUE_DRIFT  = 0.05         # rainbow drift; rush it with 0.40 0<n<1
SQUASH     = 0.5          # dot orbit: 1.0 round, 0.1 a flat pancake 0<n<2
LINE_W     = 2.0          # spoke thickness; try a chunky 6.0 0<n<12
BUD_REACH  = 0.7          # little dot's position along the spoke; try 0.3 0<n<1
BUD_OFFSET = 6            # little dot's sideways step; 30 makes sharp petals -40<n<40
BUD_SCALE  = 0.5          # little dot size compared with the big one 0<n<2
# The four MIN/MAX pairs below fence the dice: each CONFIRM rolls a fresh
# spot inside every fence, and dragging a fence reshapes the current roll.
ORBIT_MIN  = 30           # closest big-dot orbit; try 10 0<n<120
ORBIT_MAX  = 80           # furthest big-dot orbit; 118 gets wild 0<n<120
SPIN_MIN   = 0.6          # slowest reroll spin; try a lazy 0.1 0<n<6
SPIN_MAX   = 2.2          # fastest reroll spin; try a dizzy 5.0 0<n<6
DOT_MIN    = 6            # smallest possible dot radius; try 12 0<n<40
DOT_MAX    = 16           # biggest possible dot radius; try 30, then CONFIRM 0<n<40
REACH_MIN  = 60           # shortest possible spoke; try 20 0<n<120
REACH_MAX  = 105          # longest possible spoke; 118 kisses the rim 0<n<120

# --------------------------- pick the colours ---------------------------------
# TINT filters everything: white keeps the rainbow honest, anything else washes
# glass and LEDs together -- tap its swatch for a picker. Then choose whether
# the motif wears three shuffles of the moving rainbow or is dipped whole into
# one colour of it. Clicking a name repaints the flake you already have; its
# shape is carried across edits.
TINT = (1.0, 1.0, 1.0)    # try a rose wash (1.0, 0.6, 0.8)
COLOURS = "rainbow"     #: rainbow
# COLOURS = "single"    #: single

TAU = 6.28318   # one full turn in radians -- the maths needs it exact, so let it be

# ---------------------------- a pocket rainbow --------------------------------
# rainbow() walks the six edges of the colour wheel -- the same journey as the
# "hue" slider in a colour picker: https://en.wikipedia.org/wiki/HSL_and_HSV

def rainbow(h):
    # Walk hue 0..1 along the six edges of the colour wheel to make (r,g,b).
    h = h % 1.0   # Modulo also wraps negative hues safely back into 0..1.
    i = int(h * 6)
    f = h * 6 - i
    q = 1.0 - f
    if i == 0:
        return (1.0, f, 0.0)
    if i == 1:
        return (q, 1.0, 0.0)
    if i == 2:
        return (0.0, 1.0, f)
    if i == 3:
        return (0.0, q, 1.0)
    if i == 4:
        return (f, 0.0, 1.0)
    return (1.0, 0.0, q)


def tinted(colour):
    # Filter a colour through the TINT swatch, channel by channel.
    return (colour[0] * TINT[0], colour[1] * TINT[1], colour[2] * TINT[2])


class Kaleidoscope(app.App):
    """Reroll a small motif, then let rotational symmetry make it bloom."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False      # have we taken the screen and LEDs yet?
        self.t = 0.0         # seconds since start
        self.symi = 0        # which entry of SYMMETRIES is live
        # Angles accumulate a little every frame, so dragging a speed knob
        # (WHIRL, HUE_DRIFT, SPIN_*) steers the motion smoothly instead of
        # teleporting the flake to wherever the new speed "would have" put it.
        self.whirl_a = 0.0   # whole-flake rotation so far, in radians
        self.spin_a = 0.0    # big-dot orbit angle so far
        self.hue_a = 0.0     # rainbow distance travelled so far
        self.reseed()

    # Carry time, the symmetry choice, the accumulated angles and the dice
    # rolls, so edits reshape the living flake. TINT and COLOURS stay out: an
    # edit re-reads them from the constants above, so a click on a name or a
    # tap on the swatch repaints instantly.
    __live_state__ = ("fg", "t", "symi", "roll_orbit", "roll_spin",
                      "roll_dot", "roll_reach", "spin_dir", "hue",
                      "whirl_a", "spin_a", "hue_a", "born")

    def reseed(self):
        # Throw the dice, but keep every roll as a plain 0..1 amount. The
        # real orbit, spin, size and reach are mixed from these each frame,
        # so dragging a MIN or MAX knob reshapes the flake you are watching --
        # only CONFIRM rolls new dice.
        self.roll_orbit = random.random()
        self.roll_spin = random.random()
        self.roll_dot = random.random()
        self.roll_reach = random.random()
        self.spin_dir = random.choice((-1, 1))
        self.hue = random.random()
        self.born = self.t   # When these dice were thrown -- the bloom clock.

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())   # Borrow the LED ring.
            self.fg = True
        dt = delta / 1000.0
        self.t += dt
        self.whirl_a += dt * WHIRL
        self.hue_a += dt * HUE_DRIFT
        self.spin_a += dt * (SPIN_MIN + self.roll_spin *
                             (SPIN_MAX - SPIN_MIN)) * self.spin_dir
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            eventbus.emit(PatternEnable())    # Hand the ring back.
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["CONFIRM"]):
            b.clear()
            self.reseed()
        if b.get(BUTTON_TYPES["RIGHT"]):
            b.clear()
            self.symi = (self.symi + 1) % len(SYMMETRIES)
        if b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.symi = (self.symi - 1) % len(SYMMETRIES)
        self.light_ring()
        return True

    def mix(self):
        # The mirrors stamp one identical motif 2*sym times a frame, so mix
        # the dice, the trig and the colours once here -- not once per stamp.
        orbit = ORBIT_MIN + self.roll_orbit * (ORBIT_MAX - ORBIT_MIN)
        dot = DOT_MIN + self.roll_dot * (DOT_MAX - DOT_MIN)
        reach = REACH_MIN + self.roll_reach * (REACH_MAX - REACH_MIN)
        ox = math.cos(self.spin_a) * orbit         # The big dot rides an oval.
        oy = math.sin(self.spin_a) * orbit * SQUASH
        raw = rainbow(self.hue + self.hue_a)
        if COLOURS == "single":
            big = bud = raw                        # The whole motif, dipped once.
        else:
            # Shuffle the raw rainbow BEFORE tinting, so a rose TINT stays
            # rose on every element instead of wandering to another channel.
            big, bud = (raw[2], raw[0], raw[1]), (raw[1], raw[2], raw[0])
        return (ox, oy, dot, reach, tinted(raw), tinted(big), tinted(bud))

    def motif(self, ctx, m):
        # This one motif -- a spoke and two riding dots -- makes the whole image.
        ox, oy, dot, reach, spoke, big, bud = m
        ctx.rgba(spoke[0], spoke[1], spoke[2], 0.9)
        ctx.move_to(0, 0).line_to(reach, 0).stroke()
        ctx.rgba(big[0], big[1], big[2], 0.85)
        ctx.arc(ox, oy, dot, 0, TAU, True).fill()
        ctx.rgba(bud[0], bud[1], bud[2], 0.6)
        # The little dot rides partway along and just off to the side of the spoke.
        ctx.arc(reach * BUD_REACH,
                BUD_OFFSET,
                dot * BUD_SCALE, 0, TAU, True).fill()

    def light_ring(self):
        # The bezel answers the glass: the same rainbow at twelve stops,
        # washed by the same TINT, turning in step with the flake's WHIRL.
        base = self.hue + self.hue_a - self.whirl_a / TAU
        for i in range(1, 13):
            r, g, b = tinted(rainbow(base + i / 12.0))
            tildagonos.leds[i] = (int(r * 255), int(g * 255), int(b * 255))
        tildagonos.leds.write()

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        sym = SYMMETRIES[self.symi % len(SYMMETRIES)]
        m = self.mix()                     # Everything the stamps share, made once.
        ctx.line_width = LINE_W
        ctx.save()
        ctx.rotate(self.whirl_a)           # The whole flake turns slowly.
        pop = min(1.0, (self.t - self.born) / 0.4)
        ctx.scale(pop * (2 - pop), pop * (2 - pop))   # A fresh roll blooms in.
        for s in range(sym):
            ctx.save()
            ctx.rotate(TAU * s / sym)      # swing round to this wedge...
            self.motif(ctx, m)             # ...stamp the doodle...
            ctx.scale(1.0, -1.0)           # ...flip it like a mirror...
            self.motif(ctx, m)             # ...and stamp its twin
            ctx.restore()
        ctx.restore()
        ctx.rgb(0.7, 0.7, 0.7)             # Name the symmetry, like tixy does.
        ctx.font_size = 16
        ctx.text_align = ctx.CENTER
        ctx.move_to(0, 112).text(str(sym) + "-fold")
        ctx.restore()


__app_export__ = Kaleidoscope

# ------------------------------ try this --------------------------------------
# - Drag ORBIT_MAX slowly and watch the same flake stretch -- a drag never
#   rerolls the design; only CONFIRM throws the dice. Barely moving? This roll
#   landed near ORBIT_MIN, so drag that knob instead, or CONFIRM a new roll.
# - Tap the TINT swatch and pull the picker around: glass and bezel LEDs wash
#   together, because both drink from the same rainbow() above.
# - Raise the 12 in SYMMETRIES to 24, then press RIGHT until the label says
#   24-fold: the snowflake becomes a lace doily.
# - For a finale, drop SQUASH to 0.05: every orbit flattens onto its spoke and
#   the flower keeps collapsing through the centre and bursting back out.
#   Raise SPIN_MAX to 5.0 and CONFIRM until you catch a fast one.
```

---

## 7 · hopalong

**Hopalong attractor** · [play with it live](https://protogon.codemyriad.io/editor/#hopalong) · [see also](https://en.wikibooks.org/wiki/Fractals/Hopalong)

```python
# hopalong (7) -- Hopalong attractor. One point follows a simple rule; hundreds
# of hops reveal a slowly turning cloud of swirls and petals.
#
# HOW IT WORKS
#   A single point plays hopscotch. For each jump, it takes its current position
#   (x, y) and three fixed numbers a, b, and c, then applies this rule:
#       new x = y - sign(x) * sqrt(|b*x - c|)        new y = a - x
#   There is no randomness in the motion, yet its landing spots gather into
#   swirls and petals nobody designed. We keep the newest few hundred on
#   screen, colour them like a rainbow by age, and slowly turn the whole cloud.
#
# See also:  Barry Martin's Hopalong attractor, popularised by A. K. Dewdney in
#            Scientific American (Sept 1986) -- https://en.wikibooks.org/wiki/Fractals/Hopalong
#
# BUTTONS   LEFT/RIGHT previous/next preset (fresh cloud) - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
# Each preset is (a, b, c, zoom). The attractor is sensitive: nudge a, b,
# or c a little and a completely different creature grows in its place.
PRESETS = ((-2.0, 0.35, 1.2, 3.6),   # drag the 0.35 very slowly
           (2.1, 1.9, 0.5, 2.2),
           (-3.1, 0.2, 1.9, 2.6),
           (1.3, 1.3, 1.3, 3.0))
POINTS = 14       # new hops per frame; try 30 to fill in faster 0<n<40
# Each retained spot is one draw call; about 500 per frame is the badge's budget.
KEEP = 440        # spots kept on screen; try 150 for wispy trails 0<n<600
SPIN = 0.15       # cloud rotation speed; -0.15 reverses, 0.6 whirls -1<n<1
ZOOM = 7.0        # base pixels per maths unit; presets scale this too 0<n<20
DOT = 1.6         # size of each spot; try 3.0 for chunky stars 0<n<6
HUE_DRIFT = 0.1   # colour cycling speed; 0.6 makes a disco 0<n<1
HUE_STEP = 0.0006 # hue change between consecutive hops; 0 is one colour -0.01<n<0.01
OLD_ALPHA = 0.25  # opacity of the oldest spots; 0 is misty, 1 keeps every spot 0<n<1

# ------------------------------- the hop -------------------------------------

def sign(v):
    # Return -1, 0, or +1 to say which side of zero v lies on.
    return 1.0 if v > 0 else (-1.0 if v < 0 else 0.0)


def hop(x, y, a, b, c):
    # Barry Martin's rule: the next spot depends only on the current one.
    return y - sign(x) * math.sqrt(abs(b * x - c)), a - x


def rainbow(h):
    # Turn a number into a colour around the wheel; only its fraction matters.
    h = h % 1.0
    i = int(h * 6)           # which sixth of the wheel we are in
    f = h * 6 - i            # how far into that sixth
    q = 1.0 - f
    if i == 0:
        return (1.0, f, 0.0)
    if i == 1:
        return (q, 1.0, 0.0)
    if i == 2:
        return (0.0, 1.0, f)
    if i == 3:
        return (0.0, q, 1.0)
    if i == 4:
        return (f, 0.0, 1.0)
    return (1.0, 0.0, q)


class Hopalong(app.App):
    """Hop the point several times per frame and draw its newest landings."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False       # have we taken the screen yet?
        self.t = 0.0          # seconds since start
        self.preset = 0       # which (a, b, c, zoom) row is live
        self.restart()

    def restart(self):
        # Return to the origin with an empty trail: new constants, new creature.
        self.x = 0.0
        self.y = 0.0
        self.pts = []

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += delta / 1000.0
        buttons = self.button_states
        if buttons.get(BUTTON_TYPES["CANCEL"]):
            buttons.clear()
            self.minimise()
            return False
        if buttons.get(BUTTON_TYPES["RIGHT"]):
            buttons.clear()
            self.preset = (self.preset + 1) % len(PRESETS)
            self.restart()
        if buttons.get(BUTTON_TYPES["LEFT"]):
            buttons.clear()
            self.preset = (self.preset - 1) % len(PRESETS)
            self.restart()
        a, b, c, _ = PRESETS[self.preset % len(PRESETS)]
        x, y = self.x, self.y
        for _ in range(POINTS):
            x, y = hop(x, y, a, b, c)
            self.pts.append((x, y))
        self.x, self.y = x, y
        keep = max(0, KEEP)
        if keep == 0:
            self.pts = []
        elif len(self.pts) > keep:
            self.pts = self.pts[-keep:]      # Forget the oldest landings.
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        scale = PRESETS[self.preset % len(PRESETS)][3] * ZOOM
        ctx.rotate(self.t * SPIN)            # The whole cloud turns as one.
        n = len(self.pts)
        base = self.t * HUE_DRIFT
        for idx in range(n):
            px, py = self.pts[idx]
            x = px * scale
            y = py * scale
            if x < -118 or x > 118 or y < -118 or y > 118:
                continue                     # Skip points beyond the glass.
            r, g, b = rainbow(base + idx * HUE_STEP)
            alpha = OLD_ALPHA + (1.0 - OLD_ALPHA) * (idx / n)
            ctx.rgba(r, g, b, alpha)          # Newest spots glow brightest.
            ctx.rectangle(x, y, DOT, DOT).fill()
        ctx.restore()


__app_export__ = Hopalong

# ------------------------------ try this --------------------------------------
# - Ease the first preset's 0.35 towards 1.0 in tiny steps. At every stop, the
#   creature melts and grows into a new shape.
# - Set HUE_STEP to 0.006 and OLD_ALPHA to 0.05. Rainbow bands ripple through a
#   trail whose oldest landings dissolve almost completely into mist.
# - Add (1.1, 0.5, 1.0, 2.5) to the end of PRESETS, then choose it with RIGHT.
#   This gentle creature stays almost entirely on the glass.
```

---

## 8 · matrixrain

**Digital rain** · [play with it live](https://protogon.codemyriad.io/editor/#matrixrain) · [see also](https://en.wikipedia.org/wiki/Matrix_digital_rain)

```python
# matrixrain (8) -- Digital rain. Luminous glyphs stream down the round display,
# each column trailing a bright head and a fading tail.
#
# HOW IT WORKS
#   Each column is a falling ribbon of letters with its own speed and tail
#   length. On every frame, it moves by speed × that frame's duration, flickers
#   an occasional letter, and checks whether its tail has left the bottom. When
#   the whole tail is gone, the ribbon returns above the top with a fresh speed
#   and length. Glyphs beyond the round glass are simply left undrawn.
#
# See also:  Matrix digital rain -- https://en.wikipedia.org/wiki/Matrix_digital_rain
#
# BUTTONS   LEFT/RIGHT previous/next colour - CANCEL exits
import app
import random
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
COLS     = 15     # rain columns; try 8 (sparse) or 22 (dense) 0<n<32
CELL     = 15     # glyph size and row spacing in px; try a chunky 24 4<n<40
FALL_MIN = 45.0   # slowest column in px/sec 0<n<200
FALL_MAX = 130.0  # fastest column in px/sec; try a stormy 300.0 0<n<400
TAIL_MIN = 6      # shortest tail in glyphs 0<n<40
TAIL_MAX = 16     # longest tail in glyphs; try long streamers at 24 0<n<40
FLICKER  = 0.15   # chance per frame of a letter swap; 0.9 boils 0<n<1
TINTS = ((0.3, 1.0, 0.4),   # phosphor green   <- cycle these with LEFT/RIGHT
         (1.0, 0.7, 0.1),   # amber terminal
         (0.3, 0.9, 1.0),   # ice cyan
         (0.8, 0.4, 1.0))   # violet
HEAD_TINT = (0.9, 1.0, 0.9) # colour of each bright leading glyph
TAIL_GLOW = 0.25             # faintest tail opacity; 0 vanishes into black 0<n<1

GLYPHS = "0123456789ABCDFHKMNPXYZ#*<>/"   # the alphabet the rain is made of
R2 = 116 * 116            # The glass is round: skip glyphs past radius 116.


def new_column(x, head_y):
    # Make one ribbon: its position, falling speed, length, and letters.
    shortest = min(TAIL_MIN, TAIL_MAX)
    longest = max(TAIL_MIN, TAIL_MAX)
    tail = max(1, random.randint(shortest, longest))
    return {"x": x,
            "y": head_y,
            "speed": random.uniform(FALL_MIN, FALL_MAX),
            "tail": tail,
            "glyphs": [random.choice(GLYPHS) for _ in range(tail)]}


class Matrixrain(app.App):
    """Lower every column, flicker an occasional letter, and redraw the rain."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False     # Have we taken the screen yet?
        self.tint = 0       # Which colour is live.
        # Place one column in each slot across the screen, already mid-fall.
        span = (COLS - 1) * CELL
        self.cols = [new_column(-span / 2.0 + c * CELL,
                                random.uniform(-140, 40))
                     for c in range(COLS)]

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        dt = delta / 1000.0
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            self.tint = (self.tint + step) % len(TINTS)
        for col in self.cols:
            col["y"] += col["speed"] * dt
            if random.random() < FLICKER:            # Swap one letter, anywhere.
                glyph = random.randint(0, len(col["glyphs"]) - 1)
                col["glyphs"][glyph] = random.choice(GLYPHS)
            if col["y"] - col["tail"] * CELL > 128:  # The whole tail is off-screen.
                col.update(new_column(col["x"], -140))
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        ctx.font_size = CELL
        ctx.text_align = ctx.LEFT
        tint = TINTS[self.tint % len(TINTS)]
        for col in self.cols:
            x = col["x"]
            head = col["y"]
            for k in range(col["tail"]):
                gy = head - k * CELL                 # k glyphs behind the head.
                if gy < -120 or gy > 120:
                    continue
                if x * x + gy * gy > R2:             # off the round glass
                    continue
                if k == 0:
                    ctx.rgb(*HEAD_TINT)
                else:
                    fade = 1.0 - k / col["tail"]     # 1 at the head, 0 at the tip.
                    ctx.rgba(tint[0] * fade, tint[1] * fade, tint[2] * fade,
                             TAIL_GLOW + (1.0 - TAIL_GLOW) * fade)
                ctx.move_to(x, gy).text(col["glyphs"][k])
        ctx.restore()


__app_export__ = Matrixrain

# ------------------------------ try this --------------------------------------
# - Set GLYPHS = "01" for binary rain, or hide a message in it: "EMF2026 ".
# - Raise FLICKER to 0.9 and the letters boil; at 0.0, each column's text freezes.
# - Set HEAD_TINT to (1.0, 0.3, 0.1) and TAIL_GLOW to 0.05. Ember-red heads
#   streak out of tails that disappear into the black.
```

---

## 9 · plasma

**Plasma tiles** · [play with it live](https://protogon.codemyriad.io/editor/#plasma) · [see also](https://en.wikipedia.org/wiki/Plasma_effect)

```python
# plasma (9) -- Plasma tiles. Four drifting sine waves wash colour across a
# tile grid: classic demoscene plasma without a per-pixel framebuffer.
#
# HOW IT WORKS
#   Every frame, each tile asks, "How hot am I right now?" Four sine waves
#   provide the answer: one travels across the screen, one down it, one on a
#   diagonal, and one ripples out from the centre. Where their crests gather,
#   a tile glows hot; where the waves cancel, it cools. A row of editable colour
#   stops turns that shifting heat into a palette -- and that is plasma.
#
# See also:  the plasma effect -- https://en.wikipedia.org/wiki/Plasma_effect
#
# BUTTONS   LEFT/RIGHT previous/next palette - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
GRID      = 16               # tiles per side; try 8 for chunks; 24 taxes the badge 4<n<24
SPEED     = 1.0              # animation speed; try 0.3 for drifting or 3.0 for boiling 0<n<5
WAVE_X    = 0.6              # horizontal wave frequency; try 0.2 for broad bands 0<n<2
WAVE_Y    = 0.7              # vertical wave frequency; try 0.0 to flatten it 0<n<2
WAVE_DIAG = 0.45             # diagonal wave frequency; try 1.5 for stripes 0<n<2
RIPPLE    = 0.7              # radial wave frequency; try 2.0 for a tight bullseye 0<n<3
DRIFT_Y   = 1.1              # vertical wave travel speed; negative reverses -3<n<3
DRIFT_DIAG = 0.7             # diagonal wave travel speed; negative reverses -3<n<3
DRIFT_RIPPLE = 1.7           # ring travel speed; 0.0 freezes the rings -3<n<3

# Each palette runs from cold to middle to hot. Tap any swatch to repaint it.
FIRE = ((0.0, 0.0, 0.0),
        (0.8, 0.3, 0.0),
        (1.0, 1.0, 0.2))
OCEAN = ((0.0, 0.3, 0.5),
         (0.1, 0.6, 0.75),
         (0.2, 0.9, 1.0))
RAINBOW = ((1.0, 0.2, 0.2),
           (1.0, 0.85, 0.1),
           (0.1, 0.9, 0.45),
           (0.2, 0.55, 1.0),
           (0.85, 0.2, 1.0))

# Click a palette to switch it live. LEFT/RIGHT cycle these choices on a badge.
PALETTE = FIRE      #: fire
# PALETTE = OCEAN   #: ocean
# PALETTE = RAINBOW #: rainbow
PALETTES = (FIRE, OCEAN, RAINBOW)

TILE = 240.0 / max(GRID, 1)  # px per tile (max() survives a drag to zero)
CENTRE = (GRID - 1) / 2.0    # the spot the ripple wave spreads from

# ------------------------------ the plasma -----------------------------------
# fx and fy describe how far this tile sits from the grid's centre.

def heat(fx, fy, t):
    # Four waves wander at their own frequencies and travel speeds.
    across = math.sin(fx * WAVE_X + t)
    down = math.sin(fy * WAVE_Y - t * DRIFT_Y)
    diag = math.sin((fx + fy) * WAVE_DIAG + t * DRIFT_DIAG)
    rings = math.sin(((fx * fx + fy * fy) ** 0.5) * RIPPLE - t * DRIFT_RIPPLE)
    # Each wave spans -1..1; squeeze their -4..4 sum into 0..1.
    return (across + down + diag + rings + 4.0) / 8.0

# ----------------------------- the palette -----------------------------------

def colour_ramp(v, stops):
    # Find the two neighbouring colour stops and blend between them.
    position = max(0.0, min(1.0, v)) * (len(stops) - 1)
    left = min(len(stops) - 2, int(position))
    mix = position - left
    a, b = stops[left], stops[left + 1]
    return (a[0] + (b[0] - a[0]) * mix,
            a[1] + (b[1] - a[1]) * mix,
            a[2] + (b[2] - a[2]) * mix)


class Plasma(app.App):
    """Combine four travelling waves and map their heat through a palette."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # Have we taken the screen yet?
        self.t = 0.0           # Plasma clock, in seconds.
        self.palette = PALETTE  # The live palette selected above.

    # Keep time across live edits, but let a clicked palette take effect.
    __live_state__ = ("t",)

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += (delta / 1000.0) * SPEED
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            here = PALETTES.index(self.palette) if self.palette in PALETTES else 0
            self.palette = PALETTES[(here + step) % len(PALETTES)]
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        t = self.t
        for gy in range(GRID):
            y = -120 + gy * TILE
            fy = gy - CENTRE
            for gx in range(GRID):
                r, g, bl = colour_ramp(heat(gx - CENTRE, fy, t), self.palette)
                ctx.rgb(r, g, bl)
                # The extra 0.6 overlaps each tile slightly, hiding fine seams.
                ctx.rectangle(-120 + gx * TILE, y, TILE + 0.6, TILE + 0.6).fill()
        ctx.restore()


__app_export__ = Plasma

# ------------------------------ try this --------------------------------------
# - Click ocean above, then tap any of its three swatches. The whole plasma
#   repaints itself while it keeps moving.
# - Set WAVE_X, WAVE_Y and WAVE_DIAG to 0.0. The sliding waves fall flat,
#   leaving a pure bullseye to pulse out from the centre.
# - Set DRIFT_RIPPLE to 0.0. The rings freeze while the other waves flow through.
```

---

## 10 · ledring

**Two rings, one clock** · [play with it live](https://protogon.codemyriad.io/editor/#ledring)

```python
# ledring (10) -- Two rings, one clock. Twelve dots on the glass mirror the
# twelve bezel LEDs, and a shared clock keeps both rings moving together.
#
# HOW IT WORKS
#   The badge has twelve LEDs around its rim. Each frame, we use one clock to
#   make twelve colours, then send every colour to two places: a physical LED
#   and the screen dot at its matching angle. Both rings receive the same
#   values, so glass and bezel stay perfectly aligned. A system service normally
#   owns the LEDs; we borrow the ring and return it when the app exits.
#
# BUTTONS   LEFT/RIGHT previous/next pattern - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent
from system.patterndisplay.events import PatternDisable, PatternEnable
from tildagonos import tildagonos

TAU = 6.28318

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
SPEED = 1.6                 # comet speed in LEDs/sec; race it at 5.0 -6<n<6
TAIL = 3.5                  # LEDs the tail fades over; try 1.5 (spark) or 6.0 0<n<16
RING = 88                   # on-screen ring radius; pull it in to 60 0<n<120
DOT_MIN = 9                 # dark LED dot radius; try 4 0<n<20
DOT_GROW = 6                # extra radius at full brightness; try 14 (blobby) 0<n<20
PULSE_RATE = 3.0            # pulse speed in radians/sec; calm it to 1.0 0<n<6
PULSE_TWIST = 0.0           # phase twist per LED; try 0.5 for a chasing wave 0<n<2
PULSE_LOW = 0.15            # dimmest point of each pulse; 0.8 barely shimmers 0<n<1
SPIN = 0.2                  # rainbow turns/sec; try -0.2 to reverse -1<n<1
RAINBOW_TURNS = 1.0         # colour-wheel laps around the ring; try 2.0 for stripes 0<n<4
COMET = (1.0, 0.6, 0.1)     # comet colour (amber); icy: (0.2, 0.6, 1.0)
PULSE = (0.1, 1.0, 1.0)     # pulse colour (teal); warm: (1.0, 0.4, 0.1)


def hue(h):
    # Walk 0..1 around the six edges of the colour wheel, then wrap back to red.
    h = h % 1.0
    i = int(h * 6)
    f = h * 6 - i
    q = 1.0 - f
    if i == 0:
        return (1.0, f, 0.0)
    if i == 1:
        return (q, 1.0, 0.0)
    if i == 2:
        return (0.0, 1.0, f)
    if i == 3:
        return (0.0, q, 1.0)
    if i == 4:
        return (f, 0.0, 1.0)
    return (1.0, 0.0, q)


def led_angle(i):
    # LED 1 sits at the top; each following LED is 1/12 turn clockwise.
    return -math.pi / 2 + i * (TAU / 12)


class Ledring(app.App):
    """Turn one clock into twelve colours shared by the LEDs and screen."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # Have we taken the screen and LEDs yet?
        self.t = 0.0           # The shared clock: seconds since start.
        self.mode = 0          # 0 comet, 1 pulse, 2 rainbow

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())   # Borrow the LED ring.
            self.fg = True
        self.t += delta / 1000.0
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            eventbus.emit(PatternEnable())    # Give the LED ring back.
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            self.mode = (self.mode + step) % 3
        return True

    def ring_colours(self):
        # Turn one clock into twelve 0..1 (r,g,b) colours and a comet position.
        t = self.t
        head = (t * SPEED) % 12               # Comet position, in LED numbers.
        travel = 1 if SPEED >= 0 else -1       # Which way the comet is moving.
        tail = max(0.001, TAIL)                # Stay safe if TAIL is dragged to 0.
        out = []
        for i in range(12):
            if self.mode == 0:                        # comet
                d = ((head - i) * travel) % 12  # Distance behind the moving head.
                f = max(0.0, 1.0 - d / tail)    # Fade towards the tip of the tail.
                out.append((f * COMET[0], f * COMET[1], f * COMET[2]))
            elif self.mode == 1:                      # pulse
                # Every LED breathes together; raise PULSE_TWIST to make a wave.
                wave = math.sin(t * PULSE_RATE - i * PULSE_TWIST)
                f = PULSE_LOW + (1.0 - PULSE_LOW) * (0.5 + 0.5 * wave)
                out.append((PULSE[0] * f, PULSE[1] * f, PULSE[2] * f))
            else:                                     # rainbow
                # RAINBOW_TURNS decides how many colour-wheel laps fit around.
                h = i / 12.0 * RAINBOW_TURNS + t * SPIN
                out.append(hue(h))
        return out, head

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        cols, head = self.ring_colours()
        for i in range(12):
            r, g, bl = cols[i]
            # Send the same colour to the physical LED and its screen dot.
            tildagonos.leds[i + 1] = (int(r * 255), int(g * 255), int(bl * 255))
            a = led_angle(i)
            x = RING * math.cos(a)
            y = RING * math.sin(a)
            ctx.rgb(max(0.0, min(1.0, r)), max(0.0, min(1.0, g)),
                    max(0.0, min(1.0, bl)))
            bright = (r + g + bl) / 3.0       # A brighter LED gets a bigger dot.
            ctx.arc(x, y, DOT_MIN + DOT_GROW * bright, 0, TAU, True).fill()
        tildagonos.leds.write()
        if self.mode == 0:
            # A hand marks the comet head on the glass.
            a = led_angle(head)
            ctx.line_width = 3
            ctx.rgb(0.9, 0.9, 0.9)
            ctx.move_to(0, 0).line_to(RING * 0.7 * math.cos(a),
                                      RING * 0.7 * math.sin(a)).stroke()
        ctx.rgb(0.6, 0.6, 0.6)
        ctx.font_size = 15
        ctx.text_align = ctx.CENTER
        ctx.move_to(0, 4).text(("comet", "pulse", "rainbow")[self.mode % 3])
        ctx.restore()


__app_export__ = Ledring

# ------------------------------ try this --------------------------------------
# - In pulse mode, raise PULSE_TWIST towards 0.5 and PULSE_LOW towards 0.6. A
#   gentle wave chases itself around the ring without ever going dark.
# - In rainbow mode, set RAINBOW_TURNS to 2.0: two complete rainbows wrap around
#   the LEDs. Set SPIN negative and both bands flow backwards.
# - In comet mode, make SPEED negative (-1.6). Comet, pointer, and LEDs all
#   reverse together because all three read the same clock.
```

---

## 11 · pipes

**Growing pipes** · [play with it live](https://protogon.codemyriad.io/editor/#pipes) · [see also](https://devblogs.microsoft.com/oldnewthing/20240611-00/?p=109881)

```python
# pipes (11) -- Growing pipes. A bright head wanders across a hidden grid,
# laying colourful pipe behind it like the classic screensaver.
#
# HOW IT WORKS
#   Beneath the screen is a grid of small cells. At each step, the pipe's head
#   finds its empty neighbours and chooses one, with a fondness for carrying
#   straight on. That preference creates the long, satisfying runs. When a
#   pipe boxes itself in, another begins on an empty cell in the next colour.
#   The ring of 12 LEDs is a gauge of how crowded the board is: the moment it
#   fills all the way round, the ring flares white, the board wipes clean and
#   the plumbing starts afresh.
#
# See also:  the Windows NT "3D Pipes" screensaver --
#            https://devblogs.microsoft.com/oldnewthing/20240611-00/?p=109881
#            the head walks a "self-avoiding walk", a real maths puzzle --
#            https://en.wikipedia.org/wiki/Self-avoiding_walk
#
# BUTTONS   LEFT/RIGHT previous/next palette - UP/DOWN faster/slower -
#           CONFIRM clears the board - CANCEL exits
import app
import random
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent
from system.patterndisplay.events import PatternDisable, PatternEnable
from tildagonos import tildagonos

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
GRID     = 24     # cells per side; try 12 with CELL 18 for chunky pipes 4<n<26
CELL     = 9      # grid spacing in px; try 18 with GRID 12 4<n<20
SPEED    = 2      # growth steps per frame; 0 pauses, 6 is time-lapse 0<n<12
STRAIGHT = 0.7    # straight-ahead preference; try 0.95 for long runs or 0.0 for no bias 0<n<1
FULL     = 0.72   # fill fraction before a wipe; try 0.98 for the last gaps 0<n<1
PIPE_W   = 5      # pipe width in px; every pipe already laid fattens live 0<n<20
HEAD_R   = 4      # glowing head radius in px; try 7 for a bright beacon 0<n<12
HEAD_COL = (1.0, 1.0, 1.0)   # wandering bead colour; try hot red (1.0, 0.1, 0.0)

# --------------------------- pick the palette ---------------------------------
# Three wardrobes for the same plumbing. Click a name and every pipe already
# on screen is redressed -- the board itself is carried across your edits, so
# only the colours change. Each (r, g, b) triple is a tappable swatch, and a
# palette can hold any number of them -- just keep at least one.
NEON    = ((0.2, 0.9, 1.0), (1.0, 0.5, 0.2), (0.5, 1.0, 0.4),
           (1.0, 0.3, 0.7), (0.8, 0.8, 0.3), (0.6, 0.5, 1.0))
CLASSIC = ((0.85, 0.55, 0.25), (0.75, 0.78, 0.82), (1.0, 0.8, 0.35),
           (0.45, 0.75, 0.55))            # copper, steel, brass, patina
MONO    = ((0.15, 0.65, 1.0),)            # a single cool blue for every pipe
PIPE_COLS = NEON        #: neon
# PIPE_COLS = CLASSIC   #: classic
# PIPE_COLS = MONO      #: mono

PALETTES = (NEON, CLASSIC, MONO)   # what LEFT/RIGHT cycle on a badge

DIRS = ((1, 0), (-1, 0), (0, 1), (0, -1))    # Right, left, down, up.


def cell_centre(cell):
    # Turn grid coordinates into screen pixels, keeping the grid centred.
    off = -(GRID - 1) * CELL / 2.0
    return (off + cell[0] * CELL, off + cell[1] * CELL)


class Pipes(app.App):
    """Grow colourful pipes cell by cell and illuminate the wandering head."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False            # Have we taken the screen and LEDs yet?
        self.pal = PIPE_COLS       # The palette picked above; LEFT/RIGHT cycle it.
        self.speed = SPEED         # Steps per frame; UP/DOWN adjust it live.
        self.geom = (GRID, CELL)   # The grid this board was built for.
        self.wipe_board()

    # Carry the whole board across live edits -- explicit names carry sets and
    # lists too, not just scalars -- so every drag acts on the picture you are
    # watching. The palette and speed stay out: an edit re-reads them from the
    # constants above, so clicking a palette name or dragging SPEED works.
    # flash rides along too, or every keystroke would set off the wipe bloom.
    __live_state__ = ("fg", "visited", "segs", "head", "dir", "col", "geom",
                      "flash")

    def wipe_board(self):
        # A wipe earns a small firework: light_ring() flares the bezel white
        # while flash counts down to zero.
        self.flash = 6
        self.visited = set()   # Cells already claimed by a pipe.
        self.segs = []         # Finished pieces: (x1, y1, x2, y2, colour).
        self.col = -1          # Start just before the first palette entry.
        self.start_pipe()

    def start_pipe(self):
        # Begin a fresh pipe on any empty cell, dressed in the next colour.
        # Scanning the whole board is fine here: a pipe dies only now and
        # then, so this never runs every frame.
        free = [(x, y) for x in range(GRID) for y in range(GRID)
                if (x, y) not in self.visited]
        if not free:
            self.wipe_board()
            return
        self.head = random.choice(free)
        self.dir = random.choice(DIRS)
        self.visited.add(self.head)
        self.col = (self.col + 1) % len(self.pal)

    def grow(self):
        # Take one step into an empty neighbour, favouring straight ahead.
        x, y = self.head
        opts = []
        for d in DIRS:
            nx, ny = x + d[0], y + d[1]
            if 0 <= nx < GRID and 0 <= ny < GRID and (nx, ny) not in self.visited:
                opts.append(d)
        if not opts:
            self.start_pipe()          # Boxed in: abandon this pipe and start afresh.
            return
        if self.dir in opts and random.random() < STRAIGHT:
            d = self.dir
        else:
            d = random.choice(opts)    # Turn a corner.
        nx, ny = x + d[0], y + d[1]
        self.segs.append(cell_centre((x, y)) + cell_centre((nx, ny)) + (self.col,))
        self.visited.add((nx, ny))
        self.head = (nx, ny)
        self.dir = d
        if len(self.visited) > GRID * GRID * FULL:
            self.wipe_board()          # The board is crowded; start over.

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())   # Borrow the LED ring for the gauge.
            self.fg = True
        if self.geom != (GRID, CELL):
            # The carried board was built for another grid; its pixels would
            # land in the wrong places, so start a fresh board at the new size.
            self.geom = (GRID, CELL)
            self.wipe_board()
        self.col %= len(self.pal)   # A palette click can have fewer colours.
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            eventbus.emit(PatternEnable())    # Give the LED ring back.
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["CONFIRM"]):
            b.clear()
            self.wipe_board()
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            here = PALETTES.index(self.pal) if self.pal in PALETTES else 0
            self.pal = PALETTES[(here + step) % len(PALETTES)]
        if b.get(BUTTON_TYPES["UP"]):
            b.clear()
            self.speed = min(12, self.speed + 1)
        if b.get(BUTTON_TYPES["DOWN"]):
            b.clear()
            self.speed = max(0, self.speed - 1)   # 0 pauses the plumbing.
        for _ in range(self.speed):
            self.grow()
        self.light_ring()
        return True

    def light_ring(self):
        if self.flash:
            # The wipe bloom: every LED flares white and fades over six frames.
            self.flash -= 1
            v = int(255 * self.flash / 6)
            for i in range(1, 13):
                tildagonos.leds[i] = (v, v, v)
            tildagonos.leds.write()
            return
        # The bezel is a progress bar for the wipe: LEDs fill clockwise from
        # the top as pipes claim the board, in the colour of the live pipe.
        cap = max(1.0, GRID * GRID * FULL)   # Stay safe if FULL is dragged to 0.
        lit = int(min(1.0, len(self.visited) / cap) * 12 + 0.5)
        r, g, bl = self.pal[self.col % len(self.pal)]
        for i in range(1, 13):
            if i <= lit:
                tildagonos.leds[i] = (int(r * 255), int(g * 255), int(bl * 255))
            else:
                tildagonos.leds[i] = (0, 0, 0)
        tildagonos.leds.write()

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        ctx.line_width = PIPE_W
        # A pipe lays its segments in one unbroken run, so colours arrive in
        # runs too. One pass batches each run into a single path and strokes
        # only when the colour changes -- a full board has roughly 400
        # segments, and stroking each one separately stalls the real badge.
        pal = self.pal
        last = None
        for (x1, y1, x2, y2, c) in self.segs:
            ci = c % len(pal)         # Modulo keeps old pipes on a new palette.
            if ci != last:
                if last is not None:
                    ctx.stroke()
                ctx.rgb(*pal[ci])
                last = ci
            ctx.move_to(x1, y1).line_to(x2, y2)
        if last is not None:
            ctx.stroke()
        # A bright bead in HEAD_COL marks the head, so you can watch it think.
        hx, hy = cell_centre(self.head)
        ctx.rgb(*HEAD_COL)
        ctx.arc(hx, hy, HEAD_R, 0, 6.2832, True).fill()
        if self.speed == 0:
            # A frozen screen with no words looks broken; say what happened.
            ctx.rgb(0.7, 0.7, 0.7)
            ctx.font_size = 16
            ctx.text_align = ctx.CENTER
            ctx.move_to(0, 112).text("paused -- UP resumes")
        ctx.restore()


__app_export__ = Pipes

# ------------------------------ try this --------------------------------------
# - Lower STRAIGHT to 0.0 and the head rolls the dice at every cell, scribbling
#   wildly. At 0.95, it shoots down long runs and turns only when it must.
# - Click "#: mono", then tap its swatch and drag the picker around. Every pipe
#   already laid follows, live -- the board survives your edits, only the
#   paint changes.
# - The ring is a fuse: drag FULL down and the moment it dips below the lit
#   LEDs, the board detonates under your finger. Park FULL at 0.1, press UP a
#   few times, and the plumbing wipes every few seconds -- a heartbeat you
#   conduct.
# - Press DOWN until the speed hits zero. The head freezes mid-thought, and you
#   can study the exact cells it must choose between before pressing UP again.
```

---

## 12 · cellular

**Cellular playground** · [play with it live](https://protogon.codemyriad.io/editor/#cellular) · [see also](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)

```python
# cellular (12) -- Cellular playground. Three tiny rules turn random noise into
# Life, Brian's Brain, or a cyclic chase of colour.
#
# HOW IT WORKS
#   The grid is a tiny world. A few times a second, every cell checks its eight
#   neighbours and the current rule chooses what happens next. LIFE_BIRTH and
#   LIFE_SURVIVAL hold Life's recipe; older survivors slowly blush red. In
#   BRAIN, sparks fade into resting cells, and BRAIN_BIRTH decides when a resting
#   cell ignites. In CYCLIC, the colours chase one another around a loop. Nothing
#   directs them, yet intricate patterns organise themselves.
#
# See also:  Conway's Game of Life -- https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life
#            Brian's Brain -- https://en.wikipedia.org/wiki/Brian%27s_Brain
#            cyclic CA -- https://en.wikipedia.org/wiki/Cyclic_cellular_automaton
#
# BUTTONS   LEFT/RIGHT previous/next rule - CONFIRM reseeds - CANCEL exits
import app
import random
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
GRID       = 20    # cells per side; try 14, above 20 works the badge hard 4<n<28
CELL       = 10    # cell size in px; try 11 to fill the screen 4<n<20
STEP_EVERY = 3     # frames per generation; 1 races, 8 crawls 0<n<16
SEED       = 0.32  # fraction alive on reseed; 0.1 lonely, 0.6 mobbed 0<n<1
GAP        = 0.6   # grout between cells; 0.0 fuses, 3.0 makes beads 0<n<5
AGE_TINT   = 0.1   # how fast Life survivors blush red; try 0.5 0<n<1
LIFE_BIRTH = (3,)  # live-neighbour counts that create a cell; try (3, 6) 0<n<8
LIFE_SURVIVAL = (2, 3)  # counts that keep a cell alive; try (1, 2, 3) 0<a<8 0<b<8
BRAIN_BIRTH = 2    # nearby sparks needed to ignite a resting cell 0<n<8
CYCLE_DIAGONALS = False  # True lets the colour chase cross corners
LIFE_YOUNG = (0.2, 1.0, 0.3)   # Life cell, freshly born (green)
LIFE_OLD   = (1.0, 0.4, 0.3)   # Life cell, old survivor (red blush)
FIRING     = (0.4, 0.8, 1.0)   # Brian's Brain spark colour (electric blue)
TIRED      = (0.1, 0.2, 0.4)   # Brian's Brain tired colour (dim)
CYCLE_COLOURS = ((1.0, 0.3, 0.2), (0.9, 0.8, 0.2),  # cyclic tribes:
                 (0.2, 0.8, 0.5), (0.3, 0.4, 1.0))  # red, yellow, green, blue

OFF = -(GRID - 1) * CELL / 2.0     # leftmost column, so the grid sits centred

# ------------------------------ the rules ------------------------------------
# A rule answers one question for one cell: "Given my state s and my eight
# neighbours, what will I be next?" step() asks every cell once per generation.

def life(s, nbrs):
    # Conway's Game of Life: the two neighbour-count recipes are above.
    n = sum(nbrs)
    return 1 if n in LIFE_BIRTH or (s and n in LIFE_SURVIVAL) else 0

def brain(s, nbrs):
    # Brian's Brain: a firing cell (1) tires (2), then rests (0). BRAIN_BIRTH is
    # the exact number of neighbouring sparks that wakes a resting cell.
    if s:
        return (s + 1) % 3
    return 1 if sum(1 for v in nbrs if v == 1) == BRAIN_BIRTH else 0

def cyclic(s, nbrs):
    # Each colour becomes the next when that colour touches it. Corners can count.
    eater = (s + 1) % len(CYCLE_COLOURS)
    touching = nbrs if CYCLE_DIAGONALS else (nbrs[1], nbrs[3], nbrs[4], nbrs[6])
    return eater if eater in touching else s

RULES = (life, brain, cyclic)
NAMES = ("life", "brain", "cyclic")


class Cellular(app.App):
    """Step the tiny world every few frames, then paint the result."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False    # have we taken the screen yet?
        self.rule = 0      # which rule is live
        self.frame = 0     # frames seen -- a generation every STEP_EVERY
        self.reseed()

    # Preserve the chosen rule and timing, then rebuild its grid after an edit.
    __live_state__ = ("fg", "rule", "frame")

    def reseed(self):
        # Cyclic needs all four states; Life and Brain begin with a binary soup.
        if self.rule % len(RULES) == 2:
            self.cur = [[random.randrange(len(CYCLE_COLOURS)) for _ in range(GRID)]
                        for _ in range(GRID)]
        else:
            self.cur = [[1 if random.random() < SEED else 0
                         for _ in range(GRID)] for _ in range(GRID)]
        self.age = [[0] * GRID for _ in range(GRID)]
        self.seed_shape = (self.rule % len(RULES), GRID, len(CYCLE_COLOURS))

    def step(self, rule):
        # % GRID wraps the edges: a glider leaving the right re-enters left.
        g = self.cur
        nxt = [[0] * GRID for _ in range(GRID)]
        for y in range(GRID):
            ym, yp = (y - 1) % GRID, (y + 1) % GRID
            for x in range(GRID):
                xm, xp = (x - 1) % GRID, (x + 1) % GRID
                v = rule(g[y][x], (g[ym][xm], g[ym][x], g[ym][xp], g[y][xm],
                                   g[y][xp], g[yp][xm], g[yp][x], g[yp][xp]))
                nxt[y][x] = v
                self.age[y][x] = self.age[y][x] + 1 if v else 0
        self.cur = nxt

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        # Live migration restores rule after __init__; make its new seed agree.
        if self.seed_shape != (self.rule % len(RULES), GRID, len(CYCLE_COLOURS)):
            self.reseed()
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["CONFIRM"]):
            b.clear()
            self.reseed()
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            step = 1 if b.get(BUTTON_TYPES["RIGHT"]) else -1
            b.clear()
            self.rule = (self.rule + step) % len(RULES)
            self.reseed()
        self.frame += 1
        if self.frame % max(1, STEP_EVERY) == 0:   # max() survives a drag to 0
            self.step(RULES[self.rule % len(RULES)])
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        rule = self.rule % len(RULES)
        for y in range(GRID):
            top = OFF + y * CELL - CELL / 2
            for x in range(GRID):
                v = self.cur[y][x]
                if v == 0 and rule != 2:
                    continue          # State 0 stays black in Life and Brain.
                if rule == 0:
                    blush = min(1.0, self.age[y][x] * AGE_TINT)
                    ctx.rgb(LIFE_YOUNG[0] + (LIFE_OLD[0] - LIFE_YOUNG[0]) * blush,
                            LIFE_YOUNG[1] + (LIFE_OLD[1] - LIFE_YOUNG[1]) * blush,
                            LIFE_YOUNG[2] + (LIFE_OLD[2] - LIFE_YOUNG[2]) * blush)
                elif rule == 1:
                    c = FIRING if v == 1 else TIRED   # Tired cells are dim.
                    ctx.rgb(c[0], c[1], c[2])
                else:
                    c = CYCLE_COLOURS[v % len(CYCLE_COLOURS)]
                    ctx.rgb(c[0], c[1], c[2])
                ctx.rectangle(OFF + x * CELL - CELL / 2, top,
                              CELL - GAP, CELL - GAP).fill()
        ctx.rgb(0.6, 0.6, 0.6)
        ctx.font_size = 14
        ctx.text_align = ctx.CENTER
        ctx.move_to(0, 112).text(NAMES[rule])
        ctx.restore()


__app_export__ = Cellular

# ------------------------------ try this --------------------------------------
# - Set CYCLE_DIAGONALS to True. Corner contact will count too, weaving the
#   colour chase into tighter storms.
# - Set LIFE_BIRTH to (3, 6). This close cousin of HighLife grows lacy structures
#   that sometimes appear to copy themselves.
# - Set BRAIN_BIRTH to 1: every lone spark blooms into a ring.
```

---

## 13 · ribbons

**Drifting ribbons** · [play with it live](https://protogon.codemyriad.io/editor/#ribbons) · [see also](https://en.wikipedia.org/wiki/Mystify)

```python
# ribbons (13) -- Drifting ribbons. Wavy anchor points are threaded into smooth
# curves, then phase-shifted copies weave themselves into a living braid.
#
# HOW IT WORKS
#   Each ribbon begins as a row of invisible anchor points spread from left
#   to right. Every frame, each anchor rides two sine waves: one broad, slow
#   swing shaped by TWIST and one gentler wobble. The result drifts like seaweed
#   in a current. quad_to then aims the curve at each anchor and carries it
#   through the next midpoint -- a classic shortcut for drawing a smooth,
#   kink-free spline. Give each copy a different phase and the ribbons braid.
#
# See also:  the old "Mystify" screensaver -- https://en.wikipedia.org/wiki/Mystify
#
# BUTTONS   LEFT/RIGHT previous/next ribbon count - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
RIBBONS    = 3      # starting count, wrapped into the button limits; try 6 1<n<12
R_MIN, R_MAX = 2, 6  # button limits; either order works; try 1, 9 1<a<12 1<b<12
ANCHORS    = 7      # anchor points per ribbon; try 4 (angular) or 12 2<n<16
SWAY       = 70.0   # vertical swing in px; try 110.0 0<n<160
SWAY_SPEED = 0.8    # speed of the broad swing; try 2.0 (frantic) 0<n<4
TWIST      = 0.9    # wave turn between neighbouring anchors; try 0.1 or 3.0 0<n<4
WOBBLE     = 20.0   # size of the gentler second wave; try 45.0 0<n<80
WOBBLE_SPEED = 0.35 # speed of the gentler wave; try 1.4 for restless curls 0<n<4
SIDE_SWAY  = 12.0   # sideways breathing in px; 0 pins anchors in place 0<n<40
THICKNESS  = 2.5    # ribbon width in px; try 6.0 (chunky) 0<n<12
GLOW       = 0.85   # ribbon opacity; try 0.40 (misty) 0<n<1
PALETTE    = ((0.3, 0.8, 1.0), (1.0, 0.4, 0.7), (0.6, 1.0, 0.5),
              (1.0, 0.8, 0.3), (0.7, 0.5, 1.0))  # sky, pink, mint, gold, lilac

SPAN = 216.0 / max(1, ANCHORS - 1)   # spacing that carries anchors across the screen

# ------------------------------ the drift ------------------------------------

def anchor_points(t, phase):
    # Where are this ribbon's anchor points right now?
    pts = []
    for i in range(ANCHORS):
        y = (math.sin(t * SWAY_SPEED + i * TWIST + phase) * SWAY
             + math.sin(t * WOBBLE_SPEED + i) * WOBBLE)
        # A quieter sideways wave lets the anchor spacing breathe as well.
        x = -108 + i * SPAN + math.cos(t * 0.5 + i * 1.3 + phase) * SIDE_SWAY
        pts.append((x, y))
    return pts


class Ribbons(app.App):
    """Drift the anchors and thread a smooth ribbon through each set."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False    # Have we taken the screen yet?
        self.t = 0.0       # seconds since start
        self.shift = 0     # how far the buttons have walked from RIBBONS

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += delta / 1000.0
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]):
            b.clear()
            self.shift += 1
        if b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.shift -= 1
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        # Wrap the button offset between the two limits, whichever comes first.
        lo, hi = min(R_MIN, R_MAX), max(R_MIN, R_MAX)
        count = lo + (RIBBONS - lo + self.shift) % (hi - lo + 1)
        ctx.line_width = THICKNESS
        for r in range(count):
            phase = r * (6.28318 / count)   # Spread copies evenly around the wave.
            pts = anchor_points(self.t, phase)
            colour = PALETTE[r % len(PALETTE)]
            ctx.rgba(colour[0], colour[1], colour[2], GLOW)
            ctx.move_to(pts[0][0], pts[0][1])
            for i in range(1, ANCHORS - 1):
                # Bend toward this anchor, then glide through the next midpoint.
                mid_x = (pts[i][0] + pts[i + 1][0]) / 2.0
                mid_y = (pts[i][1] + pts[i + 1][1]) / 2.0
                ctx.quad_to(pts[i][0], pts[i][1], mid_x, mid_y)
            ctx.line_to(pts[ANCHORS - 1][0], pts[ANCHORS - 1][1])
            ctx.stroke()
        ctx.restore()


__app_export__ = Ribbons

# ------------------------------ try this --------------------------------------
# - Push SWAY to 110.0 and THICKNESS to 6.0 for bold ribbons that fill the badge.
# - Set WOBBLE to 0.0 for one clean, repeating braid. Ease it back up and watch
#   the pattern loosen into a lazy drift.
# - Lower TWIST to 0.1 for long, lazy arcs or raise it to 3.0 for cheerful
#   scribbles. Set SIDE_SWAY to 0.0 if you want their spacing to stay perfectly
#   neat.
```

---

## 14 · metaballs

**Orbiting metaballs** · [play with it live](https://protogon.codemyriad.io/editor/#metaballs) · [see also](https://en.wikipedia.org/wiki/Metaballs)

```python
# metaballs (14) -- Orbiting metaballs. Glowing blobs circle the centre and
# seem to melt into one another whenever their halos overlap.
#
# HOW IT WORKS
#   True metaballs calculate a "goo field" at every pixel -- too much work for
#   this animation. Here, each blob is a stack of translucent discs: small and
#   bright at the core, broad and faint at the edge. Colour builds up wherever
#   those discs overlap. When two blobs draw close, their halos form a bright
#   bridge and your eye completes the illusion of liquid merging. AXIS_PACE
#   shapes every orbit; SCATTER gives each blob a slightly different rhythm, so
#   they keep drifting apart and meeting again.
#
# Credits:   Jim Blinn invented metaballs for Carl Sagan's Cosmos --
#            https://en.wikipedia.org/wiki/Metaballs
#
# BUTTONS   LEFT/RIGHT previous/next blob count (3-5) - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

TAU = 6.28318

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
SPEED   = 1.0    # pace of the dance; try 0.3 for lava-lamp drift or 2.5 for a rush 0<n<5
ORBIT   = 40     # average orbit radius in px; try 70 to hug the rim 0<n<120
SWAY    = 18     # orbit breathing distance; try 60 for wild dives 0<n<80
SIZE    = 46     # blob radius in px; try 65 for one large glob 0<n<100
LAYERS  = 5      # discs per blob; try 2 for flat discs or 8 for silky light 1<n<12
HAZE    = 0.12   # base halo alpha; try 0.30 for a foggy glow 0<n<1
GLOW    = 0.26   # extra core alpha; try 0.45 for hot centres 0<n<1
AXIS_PACE = (0.6, 0.5)  # horizontal and vertical tempo 0<a<2 0<b<2
SCATTER = (0.12, 0.10)  # extra tempo per blob; (0, 0) flies in formation 0<a<1 0<b<1
Y_SCALE = 0.9    # orbit height-to-width ratio; 0.3 skims the horizon 0<n<2
PALETTE = ((1.0, 0.3, 0.3), (0.3, 0.6, 1.0), (0.4, 1.0, 0.5),
           (1.0, 0.8, 0.2), (0.9, 0.4, 1.0))   # one colour per blob

# ------------------------------ one blob --------------------------------------

def draw_blob(ctx, x, y, colour):
    # Paint from the core out: each disc grows larger and fainter, giving the
    # blob a soft fade instead of a hard edge.
    for layer in range(LAYERS):
        heat = (LAYERS - layer) / LAYERS       # 1.0 at the core, ~0 outside
        radius = SIZE * (0.25 + 0.75 * (layer + 1) / LAYERS)
        ctx.rgba(colour[0], colour[1], colour[2], HAZE + GLOW * heat)
        ctx.arc(x, y, radius, 0, TAU, True).fill()


class Metaballs(app.App):
    """Guide each blob around a wobbly orbit and paint its layered glow."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # Have we taken the screen yet?
        self.t = 0.0           # Seconds since launch, scaled by SPEED.
        self.count = 3         # Blobs on screen; RIGHT/LEFT cycle through 3-5.

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        self.t += (delta / 1000.0) * SPEED
        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]):
            b.clear()
            self.count = 3 + (self.count - 2) % 3    # 3 -> 4 -> 5 -> 3
        if b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.count = 3 + (self.count - 1) % 3    # 3 -> 5 -> 4 -> 3
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        t = self.t
        for k in range(self.count):
            # Start with the blobs evenly spaced around the circle...
            start = k * (TAU / self.count)
            # ...then let every orbit breathe in and out on its own rhythm.
            orbit = ORBIT + SWAY * math.sin(t * 0.5 + k)
            # SCATTER adds a little more tempo for each successive blob.
            x = math.cos(t * (AXIS_PACE[0] + SCATTER[0] * k) + start) * orbit
            y = (math.sin(t * (AXIS_PACE[1] + SCATTER[1] * k) + start)
                 * orbit * Y_SCALE)
            draw_blob(ctx, x, y, PALETTE[k % len(PALETTE)])
        ctx.restore()


__app_export__ = Metaballs

# ------------------------------ try this --------------------------------------
# - Set LAYERS to 1 and the liquid becomes a handful of flat circles. The whole
#   illusion lives in those layers of fading light.
# - Set SCATTER to (0.0, 0.0) for stately formation flying, then raise its two
#   numbers and watch the dancers peel away from one another.
# - Lower Y_SCALE to 0.3. The dance flattens into a shallow band, like glowing
#   creatures gathered on the horizon.
```

---

## 15 · timescope

**Multiplication circle** · [play with it live](https://protogon.codemyriad.io/editor/#timescope) · [see also](https://en.wikipedia.org/wiki/Cardioid#Cardioid_as_envelope_of_a_pencil_of_lines)

```python
# timescope (15) -- Multiplication circle. As one number slowly changes,
# straight chords fold themselves into cardioids, flowers and delicate knots.
#
# HOW IT WORKS
#   Place N pins around a clock face. From each pin i, draw a line to
#   i * MULTIPLIER, wrapping around whenever the result passes N. A multiplier
#   of 2 draws the 2-times table; 3 draws the 3-times table. Let the multiplier
#   drift between whole numbers and the tidy diagram begins to bloom and bend.
#   ECHOES draws nearby multipliers underneath, turning that motion into a trail.
#   PALETTE colours both the chords and the badge's ring of LEDs.
#
# See also:  multiplication circles --
#            https://en.wikipedia.org/wiki/Cardioid#Cardioid_as_envelope_of_a_pencil_of_lines
#
# BUTTONS   LEFT/RIGHT table - DOWN/UP speed - CONFIRM pin count - CANCEL exits
import app
import math
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent
from system.patterndisplay.events import PatternDisable, PatternEnable
from tildagonos import tildagonos

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it. Double-click it (or tap it on a phone) for a slider.
# A "MIN<n<MAX" note sets the slider's range.
DOTS    = (72, 108, 144)          # pins around the circle; 144 is still badge-friendly
SPEEDS  = (0.0, 0.16, 0.36, 0.72) # multiplier change per second; 0.0 pauses
RADIUS  = 104.0                   # keeps chord endpoints inside the round crop
ECHOES  = (0.18, 0.07, 0.0)       # older multipliers become an instant motion trail
START_TABLE = 2.0                  # first multiplication table; try 5.0 0<n<20
COLOUR_DRIFT = (0.03, 0.04)        # screen and LED palette drift 0<a<1 0<b<1
LINE_ALPHA = (0.09, 0.41)          # faintest-to-brightest chord opacity 0<a<1 0<b<1
LINE_WIDTH = (0.55, 1.55)          # thinnest-to-boldest chord width 0<a<5 0<b<5
PIN_SIZE = 1.7                     # dots around the clock face; 0 hides them 0<n<6
# The final colour blends back into the first, so every swatch is independent.
PALETTE = ((1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0),
           (0.0, 1.0, 1.0), (0.0, 0.0, 1.0), (1.0, 0.0, 1.0))

TAU = 6.2831853


def hue(h):
    # Travel from 0 to 1 through the editable palette, then wrap to its start.
    h = (h % 1.0) * len(PALETTE)
    i = int(h)
    f = h - i
    a, b = PALETTE[i], PALETTE[(i + 1) % len(PALETTE)]
    return (a[0] + (b[0] - a[0]) * f,
            a[1] + (b[1] - a[1]) * f,
            a[2] + (b[2] - a[2]) * f)


def point(turns, radius=RADIUS):
    a = turns * TAU
    return math.cos(a) * radius, math.sin(a) * radius


class Timescope(app.App):
    """Animate a times-table circle and echo its colours on the 12 LEDs."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False
        self.t = 0.0              # The drifting part of the multiplier.
        self.shift = 0.0          # Button-controlled jumps between whole tables.
        self.dot_mode = 1         # DOTS[1] = 108
        self.speed_mode = 1       # SPEEDS[1] gives a slow drift.
        self.rebuild()

    # Keep the chosen modes and clock, but rebuild geometry derived from them.
    __live_state__ = ("fg", "t", "shift", "dot_mode", "speed_mode")

    def rebuild(self):
        self.dot_mode %= len(DOTS)
        n = DOTS[self.dot_mode]
        self.pins = [point(i / n) for i in range(n)]
        self.dot_step = max(1, n // 72)  # Thin the rim dots as pin counts rise.
        self.pin_shape = (self.dot_mode, DOTS)

    def multiplier(self):
        return START_TABLE + self.shift + self.t

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())      # Borrow the LED ring.
            self.fg = True
        # Live migration restores dot_mode after __init__; rebuild to match it.
        if self.pin_shape != (self.dot_mode, DOTS):
            self.rebuild()
        # An edited speed list may be shorter than the previously selected mode.
        self.speed_mode %= len(SPEEDS)

        b = self.button_states
        if b.get(BUTTON_TYPES["CANCEL"]):
            b.clear()
            eventbus.emit(PatternEnable())       # Hand the ring back.
            self.minimise()
            return False

        if b.get(BUTTON_TYPES["RIGHT"]):
            b.clear()
            self.shift += 1.0                    # Next exact times table.
            self.t = 0.0
        if b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.shift -= 1.0                    # Previous exact times table.
            self.t = 0.0
        if b.get(BUTTON_TYPES["UP"]):
            b.clear()
            self.speed_mode = min(len(SPEEDS) - 1, self.speed_mode + 1)
        if b.get(BUTTON_TYPES["DOWN"]):
            b.clear()
            self.speed_mode = max(0, self.speed_mode - 1)
        if b.get(BUTTON_TYPES["CONFIRM"]):
            b.clear()
            self.dot_mode = (self.dot_mode + 1) % len(DOTS)
            self.rebuild()

        self.t += delta / 1000.0 * SPEEDS[self.speed_mode]
        self.light_ring()
        return True

    def light_ring(self):
        m = self.multiplier()
        for i in range(1, 13):
            glow = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(m * TAU + i * 0.65))
            glow = glow * glow
            r, g, b = hue(i / 12 + m * COLOUR_DRIFT[1])
            tildagonos.leds[i] = (int(r * 255 * glow),
                                  int(g * 255 * glow),
                                  int(b * 255 * glow))
        tildagonos.leds.write()

    def draw_chords(self, ctx, multiplier, alpha, width):
        n = len(self.pins)
        ctx.line_width = width
        for i in range(n):
            x1, y1 = self.pins[i]
            x2, y2 = point(((i * multiplier) % n) / n)
            r, g, b = hue(i / n + multiplier * COLOUR_DRIFT[0])
            ctx.rgba(r, g, b, alpha)
            ctx.move_to(x1, y1).line_to(x2, y2).stroke()

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()

        m = self.multiplier()
        ctx.line_width = 1.0
        ctx.rgba(0.12, 0.12, 0.16, 1.0).arc(0, 0, RADIUS, 0, TAU, True).stroke()

        for e, lag in enumerate(ECHOES):
            age = (e + 1) / len(ECHOES)
            alpha = LINE_ALPHA[0] + (LINE_ALPHA[1] - LINE_ALPHA[0]) * age * age
            width = LINE_WIDTH[0] + (LINE_WIDTH[1] - LINE_WIDTH[0]) * age
            self.draw_chords(ctx, m - lag, alpha, width)

        for i in range(0, len(self.pins), self.dot_step):
            r, g, b = hue(i / len(self.pins) + m * COLOUR_DRIFT[0])
            ctx.rgba(r, g, b, 0.85)
            ctx.arc(self.pins[i][0], self.pins[i][1], PIN_SIZE, 0, TAU, True).fill()

        ctx.rgb(0.72, 0.72, 0.78)
        ctx.font_size = 16
        ctx.text_align = ctx.CENTER
        ctx.move_to(0, 112).text("%d pins  x %.2f" % (len(self.pins), m))
        ctx.restore()


__app_export__ = Timescope

# ------------------------------ try this --------------------------------------
# - Press DOWN until the motion pauses, then tap RIGHT for 3x, 4x, 5x... Each
#   whole table has a character of its own.
# - Set DOTS to (12, 24, 144) and ECHOES to (0.0,). The first two pin counts make
#   the clock-face idea clear, and removing the echoes leaves a crisp diagram.
# - Tap any PALETTE swatch to repaint both the screen and LEDs. Set COLOUR_DRIFT
#   to (0.0, 0.0) if you want the colours to hold still while the geometry moves.
```

---

## 16 · fastcode

**Part of this app is C** · [play with it live](https://protogon.codemyriad.io/editor/#fastcode)

```python
# fastcode (16) -- Part of this app is C. native/fastcode.c compiles to a
# MicroPython native module (fastcode.mpy) the badge imports at runtime -- no
# firmware rebuild, no reflashing. The live preview runs that SAME C, compiled
# to WebAssembly, so what you see as you type is your real C -- 42 is computed
# in C both here and on the badge.
#
# BUTTONS   CONFIRM rolls new numbers · CANCEL exits (on a real badge)

# Make this app's own modules importable on the badge (its folder isn't on
# sys.path there; harmless in the editor preview).
import sys
if "." in __name__:
    _d = "/" + __name__.rsplit(".", 1)[0].replace(".", "/")
    if _d not in sys.path:
        sys.path.insert(0, _d)

import random

import app
import fastcode
from events.input import Buttons, BUTTON_TYPES
from system.eventbus import eventbus
from system.scheduler.events import RequestForegroundPushEvent

# ------------------------------ tweak me -------------------------------------
BG   = (0.04, 0.05, 0.09)    # background colour
INK  = (1.0, 0.75, 0.2)      # the answer
DIM  = (0.45, 0.5, 0.62)     # captions


class FastCode(app.App):
    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False
        self.a = 20
        self.b = 22

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        if self.button_states.get(BUTTON_TYPES["CONFIRM"]):
            self.button_states.clear()
            self.a = random.randint(0, 999)
            self.b = random.randint(0, 999)
        if self.button_states.get(BUTTON_TYPES["CANCEL"]):
            self.button_states.clear()
            self.minimise()
            return False
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(*BG).rectangle(-120, -120, 240, 240).fill()
        ctx.text_align = ctx.CENTER

        ctx.rgb(*DIM).font_size = 16
        ctx.move_to(0, -58).text("fastcode.add(%d, %d)" % (self.a, self.b))

        ctx.rgb(*INK).font_size = 64
        ctx.move_to(0, 12).text(str(fastcode.add(self.a, self.b)))

        ctx.rgb(*DIM).font_size = 13
        ctx.move_to(0, 52).text("computed by native/fastcode.c")
        ctx.move_to(0, 70).text("press · for new numbers")
        ctx.restore()


__app_export__ = FastCode
```

