# Make art with code — fifteen tiny programs

A small program can be a piece of art, and changing one is the fastest way
to learn that. This file is fifteen 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.

*From the [protogon](https://github.com/codemyriad/protogon) project
(`badge-demos` branch), which also carries the host simulator and the
browser editor these run in. 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 grid](#1--tixy)** — One tiny formula drives a 16x16 field of dots; the sign picks warm/cool, the magnitude picks size. After tixy.land
2. **[Moire rings](#2--moire)** — Two families of thin rings drift past each other with a slight phase mismatch; the interference does the work, not the code
3. **[Qix tracer](#3--qix)** — Bouncing points drag fading afterimages behind them
4. **[IMU starfield](#4--starfield)** — Stars fly toward you; tilt the badge to steer the drift, and the LED ring lights the way you lean
5. **[Polar tunnel](#5--tunnel)** — A stack of concentric rings breathes and drifts until flat circles read as a 3D tunnel you are flying down
6. **[Hex kaleidoscope](#6--kaleidoscope)** — One tiny random doodle, stamped and mirrored N times around the centre -- instant snowflake
7. **[Hopalong plotter](#7--hopalong)** — One point hops by a fixed rule; a few thousand hops paint Barry Martin's strange attractor as a slowly turning cloud
8. **[Matrix rain, round-cropped](#8--matrixrain)** — Glyph columns fall with bright heads and fading tails; RIGHT cycles the colour
9. **[Plasma tiles](#9--plasma)** — Four drifting sine waves add up to paint a 16x16 tile grid -- the classic demoscene plasma, no per-pixel framebuffer needed
10. **[LED / display phase-lock](#10--ledring)** — Twelve dots on the glass sit at the same angles as the 12 bezel LEDs; one shared phase animates both in lockstep
11. **[Pipes grower](#11--pipes)** — A head walks a hidden grid laying colourful pipes with random turns, like the classic screensaver. CONFIRM clears
12. **[Cellular playground](#12--cellular)** — A wrap-around grid where every cell obeys one tiny neighbour rule: Life, Brian's Brain, or cyclic
13. **[Drift lines](#13--ribbons)** — Wavy anchor points threaded into smooth spline ribbons; a few phase-shifted copies braid together
14. **[Orbiting metaballs (lite)](#14--metaballs)** — Glowing blobs circle the centre and seem to melt together wherever their halos overlap
15. **[A multiplication circle](#15--timescope)** — One number slowly changes; the straight chords inside the circle fold into cardioids, flowers and knots

---

## 1 · tixy

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

```python
# tixy (1) -- Tixy grid. One tiny formula drives a 16x16 field of dots; the sign
# picks warm/cool, the magnitude picks size. After tixy.land.
#
# HOW IT WORKS
#   Every frame, each dot asks one little formula: "how big should I be?"
#   The formula gets the time t, the dot's column x and row y, and answers
#   with a number around -1..1:
#       positive -> warm colour      negative -> cool colour
#       close to 0 -> tiny dot       close to 1 -> big dot
#   That's the whole machine. Five formulas are included -- write your own.
#
# Credits:   tixy.land by Martin Kleppe (@aemkei) -- https://tixy.land
#
# BUTTONS   LEFT/RIGHT swap formula - UP/DOWN change speed - 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, or tap on a phone, for a slider). The
# "MIN<n<MAX" notes set each slider's range; a/b/c pick items inside a tuple.
GRID    = 16                  # 4<n<24  dots per side.. 8 = chunky, 24 chugs
SPACING = 13.0                # 6<n<24  px between dot centres.. bigger = airier
DOT_MIN = 1.5                 # 0<n<6   radius of the smallest visible dot
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 gets (t, i, x, y): t = seconds, x/y = column/row (0..GRID-1),
# i = the dot's number. Return roughly -1..1. Edit one. Break one. Add one.

def waves(t, i, x, y):
    # two ripples sliding across each other, one per 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, offset by its number -> a rolling shimmer
    return math.sin(t * 2 + i * 0.15)

def ripple(t, i, x, y):
    # rings spreading from a point, like a stone dropped in water
    # (7.5 is the centre of a 16-wide grid -- drag it to move the splash)
    d = ((x - 7.5) ** 2 + (y - 7.5) ** 2) ** 0.5
    return math.sin(d * 0.9 - t * 2.2)

def plaid(t, i, x, y):
    # horizontal waves times vertical waves = 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 that grows and shrinks with a slow heartbeat
    d = ((x - 7.5) ** 2 + (y - 7.5) ** 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 them.) It's just a commented-out
# line each: uncomment the one you want, or edit the formula above it.
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, listen to buttons, ask the formula, draw the 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 (set by the picker above)
        self.speed = 1.0
        # precompute each dot's screen position once
        origin = -(GRID - 1) * SPACING / 2.0   # centres the grid
        self.cells = [(origin + x * SPACING, origin + y * SPACING, x, y)
                      for y in range(GRID) for x in range(GRID)]

    # Carry time + speed across live edits, but NOT the formula: that way
    # clicking a new formula above swaps 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 = SPACING * 0.46          # biggest dot that still leaves a gap
        for (px, py, x, y) in self.cells:
            value = formula(self.t, y * GRID + x, x, y)
            size = abs(value)
            if size < 0.08:               # too small to see -- 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 --------------------------------------
# - click ripple above, then nudge the 7.5 in ripple() (drag or tap): the splash follows
# - in waves(), change math.cos to math.sin -- then try math.tan (chaos)
# - add your own formula:  def stripes(t, i, x, y): return math.sin(x - t * 3)
#   then add a "# LIVE = stripes  #: stripes" line to the picker and click it
```

---

## 2 · moire

**Moire rings** · [play with it live](https://protogon.codemyriad.io/editor/#moire)

```python
# moire (2) -- Moire rings. Two families of thin rings drift past each other with
# a slight phase mismatch; the interference does the work, not the code.
#
# HOW IT WORKS
#   Draw one family of thin rings. Draw a second, identical family, mirrored
#   through the middle of the screen. Each family is boring on its own -- but
#   where the two overlap, their lines almost-but-not-quite line up, and your
#   eye invents big swirling curves nobody actually drew. That ghost pattern
#   is a moire: you've seen it where two fences overlap, or when a striped
#   shirt shimmers on TV.
#
# BUTTONS   RIGHT or LEFT swap circles <-> hexagons - 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
RINGS    = 11                  # 4<n<24   rings per family ....... try 6 (airy) or 16
RING_GAP = 8.0                 # 2<n<16   px between ring radii .. squeeze them: 5.0
INNER    = 14                  # 0<n<40   innermost ring radius . try 30 (big hole)
DRIFT    = 30.0                # 0<n<100  how far a family wanders off-centre.. try 70.0
SPEED    = 1.0                 # 0<n<5    drift speed ............ try 3.0 (dizzy)
STAGGER  = 0.30                # 0<n<1    wander delay, ring to ring.. try 0.0 or 0.9
SIDES    = 6                   # 3<n<12   corners in hexagon mode.. try 3 (triangles)
LINE_W   = 1.6                 # 0<n<6    line thickness, px .... thin best: 3.0
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 thin 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):
    """Keep time, flip shapes on RIGHT, draw the two mirrored ring families."""

    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 one clock everything drifts by
        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):
        # one ring, drawn as whichever shape RIGHT has picked
        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           # thin lines moire best
        t = self.t
        spin = t * 0.4                    # how fast the hexagons rotate
        for k in range(RINGS):
            # each ring's centre wanders its own loopy path; STAGGER makes
            # ring k lag ring k-1 a little, so the family stretches and folds
            wobble = t + k * STAGGER
            ox = math.sin(wobble) * DRIFT
            oy = math.cos(wobble * 1.3) * DRIFT   # 1.3 = loopy, 1.0 = round
            radius = INNER + k * RING_GAP         # the innermost ring
            # family one: cool, outer rings a shade warmer to hint at depth
            ctx.rgba(CYAN[0] + 0.05 * k, CYAN[1], CYAN[2], 0.42)
            self.ring(ctx, ox, oy, radius, spin)
            # family two: warm, at the exact mirror spot, spinning backwards
            ctx.rgba(PINK[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, and you get
#   two targets orbiting each other instead of a swirling interference cloud
# - drag or tap the 1.3 in the wander line to 1.0 -- every centre now travels a
#   perfect circle, so the whole pattern stops morphing and just spins
# - press RIGHT for polygons, then drag or tap SIDES down to 3: drifting triangle
#   moire (4 gives diamonds, 12 is nearly 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 drag fading afterimages behind them.
#
# HOW IT WORKS
#   A few points fly around, bouncing off the walls of an invisible box.
#   Every frame we join them into one closed shape and remember it, then
#   draw the last TRAIL shapes oldest first -- old ones faint and thin,
#   new ones bright and thick. No blur trick: the trail is just yesterday's
#   shapes politely fading away. The 12 LED ring breathes the head colour.
#
# See also:  Qix, the 1981 Taito arcade game -- https://en.wikipedia.org/wiki/Qix
#
# BUTTONS   CONFIRM add a point (2->3->4) - RIGHT/LEFT palette - 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
TRAIL     = 42                 # 0<n<100   shapes remembered ....... try 12 (crisp) or 80
BOX       = 106                # 20<n<120  half-width of the bounce box ... squeeze to 60
SPEED_MIN = 70.0               # 0<n<200   slowest fresh point, pixels per second
SPEED_MAX = 110.0              # 0<n<400   fastest fresh point ..... try 300.0 (frantic)
RAINBOW   = 0.15               # 0<n<1     hue turns per second in rainbow mode: try 0.5
LED_WAVE  = 2.0                # 0<n<10    LED ring wave speed ..... try 6.0 (busy) or 0.5
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 the colour wheel: 0..1 goes red -> yellow -> green -> blue -> 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):
    """Move the points, remember their shape, draw the memories fading."""

    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.npts = 3            # how many points are bouncing (2..4)
        self.palette = 0         # 0 rainbow, 1 ember, 2 ice
        self.respawn()

    def respawn(self):
        # throw 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 = 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
        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"]):
            b.clear()
            self.palette = (self.palette + 1) % 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 today's shape, 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 the head colour, a slow wave chasing round 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              # 0 = oldest memory, 1 = right now
            ctx.line_width = 1.0 + 2.0 * age
            ctx.rgba(hr, hg, hb, age * age * 0.9)   # squared: old dies faster
            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)              # come back home: a closed 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, 3, 0, 6.2832, True).fill()
        ctx.restore()


__app_export__ = Qix

# ------------------------------ try this --------------------------------------
# - drag or tap TRAIL to 80 and SPEED_MAX to 300.0 -- long ribbons whip around the box
# - squeeze BOX down to 40: the whole dance folds into a knot mid-screen
# - in draw(), change  age * age * 0.9  to  age * 0.9  for a gentler, even fade
```

---

## 4 · starfield

**IMU starfield** · [play with it live](https://protogon.codemyriad.io/editor/#starfield)

```python
# starfield (4) -- IMU starfield. Stars fly toward you; tilt the badge to steer the
# drift, and the LED ring lights the way you lean.
#
# HOW IT WORKS
#   A star is just three numbers: x, y and a depth z that runs from 1 (far
#   away) down to 0 (right at your nose). The whole 3D trick is one divide:
#   screen position = x / z. Far stars huddle near the centre; as z shrinks
#   they slide outward, getting bigger and brighter, until they whoosh past
#   and are recycled at the back. Tilting the badge shoves the whole field
#   sideways -- that is how you steer.
#
# BUTTONS   tilt steers (W/A/S/D in the sim) - UP/DOWN warp speed - 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
STARS     = 70              # 0<n<200   how many stars ...... try 150 (a blizzard) or 20 (calm)
FOV       = 90.0            # 20<n<200  camera zoom ......... 40.0 snow globe, 160.0 warp tunnel
TILT_GAIN = 0.6             # 0<n<3     how hard a lean shoves the field .... try 2.0 (twitchy)
SMOOTH    = 0.15            # 0<n<1     steering follow-speed. 0.02 oil tanker, 0.5 instant
GROW      = 3.0             # 0<n<12    how fat a star gets as it flies past ....... try 8.0
BLUE_TINT = 0.15            # 0<n<1     icy blue on bright stars. 0.0 pure white, 0.6 deep space
WARP      = 0.65            # 0<n<3     starting warp speed .. 2.0 = hyperspace
STAR      = (1.0, 1.0, 1.0) # star tint (r,g,b) .. try (1.0, 0.9, 0.7) for warm


def read_tilt():
    # The accelerometer says which way gravity pulls, in m/s^2. Reads can
    # hiccup on real hardware and parts are missing in the sim, so fall 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 (AttributeError, OSError, 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; tilt drifts them sideways."""

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

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            eventbus.emit(PatternDisable())   # we drive the LED ring ourselves
            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
            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 the steering toward the tilt reading instead of jumping to it,
        # so it feels like banking a spaceship, not 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:
            star[2] -= self.warp * dt               # rush toward the camera
            star[0] += self.tiltx * dt * TILT_GAIN  # the lean drags the field
            star[1] += self.tilty * dt * TILT_GAIN
            # flew past us, or drifted far off to the side? recycle it
            if star[2] <= 0.05 or abs(star[0]) > 2 or abs(star[1]) > 2:
                star[0] = random.uniform(-1, 1)
                star[1] = random.uniform(-1, 1)
                star[2] = 1.0                       # back to the far plane

    def light_leds(self):
        # point the ring where you are leaning: one bright LED at the lean
        # angle with a dim one 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]
            px = star[0] / z * FOV      # the one-divide perspective trick
            py = star[1] / z * FOV
            if px * px + py * py > 118.0 * 118.0:   # off the round glass
                continue
            bright = 1.0 - z            # nearer = brighter
            if bright < 0.05:           # newborn stars are 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, 0.5 + bright * GROW, 0, 6.2832, True).fill()
        ctx.restore()


__app_export__ = Starfield

# ------------------------------ try this --------------------------------------
# - drag or tap FOV down to 40.0: the field huddles into a snow globe; now push it
#   past 150.0 and you are staring down a warp tunnel
# - set SMOOTH to 0.02 and tilt: the steering keeps gliding long after you
#   level out, like a ship slow to answer the helm
# - in fly(), change  self.warp * dt  to  self.warp * dt * (2.0 - star[2]):
#   stars now accelerate as they get close, a proper hyperspace jump
```

---

## 5 · tunnel

**Polar tunnel** · [play with it live](https://protogon.codemyriad.io/editor/#tunnel)

```python
# tunnel (5) -- Polar tunnel. A stack of concentric rings breathes and drifts
# until flat circles read as a 3D tunnel you are flying down.
#
# HOW IT WORKS
#   Every frame each ring asks one question: "how bright am I right now?"
#   The answer is a sine wave that travels outward along the stack, so a
#   band of bright, fat rings keeps rolling past you -- that reads as
#   forward motion. The depth trick: the whole stack drifts off centre,
#   but the small far-away rings drift the most while the big nearest
#   ring barely moves. Your brain sees that mismatch and says "tunnel".
#
# BUTTONS   LEFT/RIGHT cycle 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
RINGS    = 22     # 6<n<40   rings in the stack.. 10 = sparse, 34 = dense
INNER    = 6.0    # 2<n<24   radius of the smallest ring.. bigger opens the mouth
RING_GAP = 5.2    # 2<n<10   px from one ring to the next
BREATH   = 3.0    # -6<n<8   how fast the bright band rolls (negative = inward)
RIPPLE   = 0.55   # 0<n<2    wave crowding: bigger squeezes more bands in
WANDER   = 12.0   # 0<n<40   how far the tunnel mouth drifts.. 30 gets seasick

# --------------------------- pick the colours --------------------------------
# Click a scheme to switch it live (on a real badge, LEFT/RIGHT cycle them).
# Each is a tint -- how much of each channel a ring keeps. Every value is
# 0..1, so its swatch opens a colour picker; BOOST lets a channel hit full
# blast a little before the wave peaks (that's what gives fire its 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      # >1 = channels saturate early (brighter, punchier).. try 1.0

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):
    # this ring's brightness times the scheme's tint (and BOOST), capped 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, 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 (set by the picker above)

    # Carry time across live edits, not the tint: clicking a scheme swaps it in.
    __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"]):
            b.clear()
            here = PALETTE.index(self.tint) if self.tint in PALETTE else 0
            self.tint = PALETTE[(here + 1) % len(PALETTE)]
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        t = self.t
        # the tunnel mouth wanders in a slow loop -- two sines at slightly
        # different speeds, so the path never quite repeats itself
        ox = math.sin(t * 0.7) * WANDER
        oy = math.cos(t * 0.9) * WANDER
        tint = self.tint
        for k in range(RINGS):
            # this ring's point on the wave rolling outward along the stack
            glow = 0.5 + 0.5 * math.sin(k * RIPPLE - t * BREATH)
            glow = glow * glow            # squaring dims the dim -> contrast
            cr, cg, cb = tinted(glow, tint)
            ctx.line_width = 2.0 + glow * 4.0   # bright rings get fat
            # parallax: far (small) rings take the full wobble, the nearest
            # almost none -- k/RINGS says how near this ring is
            depth = k / RINGS
            ctx.rgba(cr, cg, cb, 0.5 + 0.5 * glow)  # dim rings go sheer too
            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 -- the drift stops and you get pure breathing rings
# - make BREATH negative (-3.0): the wave rolls inward and the tunnel
#   swallows you instead of spitting you out
# - click a scheme above, then click its colour swatch to invent your own tint
```

---

## 6 · kaleidoscope

**Hex kaleidoscope** · [play with it live](https://protogon.codemyriad.io/editor/#kaleidoscope)

```python
# kaleidoscope (6) -- Hex kaleidoscope. One tiny random doodle, stamped and mirrored
# N times around the centre -- instant snowflake.
#
# HOW IT WORKS
#   A real kaleidoscope holds just ONE pinch of coloured junk -- the mirrors
#   do everything else. Same trick here: each frame we draw one small doodle
#   (a spoke plus two drifting dots), rotate-stamp it N times around the
#   centre, and stamp a flipped twin each time so the wedges mirror like
#   glass. The doodle is random; the symmetry is what makes it beautiful.
#
# BUTTONS   CONFIRM new random doodle - RIGHT/LEFT change symmetry - 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

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it (double-click, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
SYMMETRIES = (6, 8, 12)   # wedge counts RIGHT/LEFT step through . try (3, 5, 7)
WHIRL      = 0.2          # -1<n<2   spin of the whole flake ....... try 1.0, or -0.4
HUE_DRIFT  = 0.05         # 0<n<1    colours creep round the rainbow ... rush them: 0.40
SQUASH     = 0.5          # 0<n<2    dot orbit shape: 1.0 round loop, 0.1 flat pancake
LINE_W     = 2.0          # 0<n<12   spoke thickness ................... chunky: 6.0
ORBIT_MIN  = 30           # 0<n<120  closest the big dot orbits ....... try 10
ORBIT_MAX  = 80           # 0<n<120  furthest the big dot orbits ...... try 118 (wild)
SPIN_MIN   = 0.6          # 0<n<6    slowest a reroll can spin ........ try 0.1 (lazy)
SPIN_MAX   = 2.2          # 0<n<6    fastest a reroll can spin ........ try 5.0 (dizzy)
DOT_MIN    = 6            # 0<n<40   smallest dot a reroll can pick ... try 12
DOT_MAX    = 16           # 0<n<40   biggest dot a reroll can pick ..... 30, then CONFIRM
REACH_MIN  = 60           # 0<n<120  shortest spoke a reroll can pick .. try 20
REACH_MAX  = 105          # 0<n<120  longest spoke a reroll can pick ... 118 kisses the rim

TAU = 6.28318

# ---------------------------- a pocket rainbow --------------------------------

def rainbow(h):
    # hue 0..1 -> (r,g,b), walking the six edges of the colour wheel
    h = h - int(h)
    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)


class Kaleidoscope(app.App):
    """Roll a random doodle, then let rotational symmetry do the beauty."""

    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.symi = 0        # which entry of SYMMETRIES is live
        self.reseed()

    def reseed(self):
        # roll a fresh doodle: how far the big dot orbits, how fast, how big
        self.orbit = random.uniform(ORBIT_MIN, ORBIT_MAX)
        self.spin = random.uniform(SPIN_MIN, SPIN_MAX) * random.choice((-1, 1))
        self.dot = random.uniform(DOT_MIN, DOT_MAX)
        self.hue = random.random()
        self.reach = random.uniform(REACH_MIN, REACH_MAX)

    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["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)
        return True

    def motif(self, ctx):
        # the ONE doodle everything is made of: a spoke and two riding dots
        angle = self.t * self.spin
        ox = math.cos(angle) * self.orbit           # the big dot rides an oval
        oy = math.sin(angle) * self.orbit * SQUASH
        r, g, b = rainbow(self.hue + self.t * HUE_DRIFT)
        ctx.line_width = LINE_W
        ctx.rgba(r, g, b, 0.9)
        ctx.move_to(0, 0).line_to(self.reach, 0).stroke()
        ctx.rgba(b, r, g, 0.85)   # same rainbow, channels shuffled: free harmony
        ctx.arc(ox, oy, self.dot, 0, TAU, True).fill()
        ctx.rgba(g, b, r, 0.6)
        ctx.arc(self.reach * 0.7,   # little dot sits 70% of the way out...
                6,                  # ...and 6 px off the spoke, so mirrors show
                self.dot * 0.5, 0, TAU, True).fill()

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        sym = SYMMETRIES[self.symi % len(SYMMETRIES)]
        ctx.rotate(self.t * WHIRL)         # the whole flake turns, slowly
        for s in range(sym):
            ctx.save()
            ctx.rotate(TAU * s / sym)      # swing round to this wedge...
            self.motif(ctx)                # ...stamp the doodle...
            ctx.scale(1.0, -1.0)           # ...flip it like a mirror...
            self.motif(ctx)                # ...and stamp its twin
            ctx.restore()
        ctx.restore()


__app_export__ = Kaleidoscope

# ------------------------------ try this --------------------------------------
# - drag or tap the 12 in SYMMETRIES up to 24, press RIGHT until it's live: lace doily
# - set WHIRL to -0.4 and SQUASH to 1.0 -- reverse spin, perfectly round orbits
# - in motif(), change line_to(self.reach, 0) to line_to(self.reach, 40):
#   every spoke bends, and the mirrors fold the bends into zigzag stars
```

---

## 7 · hopalong

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

```python
# hopalong (7) -- Hopalong plotter. One point hops by a fixed rule; a few thousand
# hops paint Barry Martin's strange attractor as a slowly turning cloud.
#
# HOW IT WORKS
#   A single point plays hopscotch. Each hop it looks at where it stands
#   (x, y) and at three fixed numbers a, b, c, then jumps:
#       new x = y - sign(x) * sqrt(|b*x - c|)        new y = a - x
#   No randomness anywhere -- yet the landing spots scatter into swirls and
#   petals nobody designed. We keep the last few hundred spots on screen,
#   colour them rainbow by age, and slowly spin 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   RIGHT/LEFT next/previous 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
# Each preset row is (a, b, c, zoom). The attractor is touchy: nudge an a, b
# or c a little and a completely different creature grows in its place.
PRESETS = ((-2.0, 0.35, 1.2, 3.6),   # try dragging 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       # 0<n<40   new hops per frame ...... try 30 (fills in faster)
KEEP = 440        # 0<n<600  spots kept on screen .... try 150 (wispy); each spot is a
                  #          draw call and ~500/frame is the badge's budget
SPIN = 0.15       # -1<n<1   cloud rotation speed .... try -0.15 (other way) or 0.6
ZOOM = 7.0        # 0<n<20   pixels per maths unit ... try 12.0 to fly in close
DOT = 1.6         # 0<n<6    size of each spot ....... try 3.0 (chunky stars)
HUE_DRIFT = 0.1   # 0<n<1    colour cycling speed .... 0.6 turns it into a disco

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

def sign(v):
    # -1, 0 or +1: which side of zero v is 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):
    # a number -> a colour around the colour wheel (only the fraction counts)
    h = h - int(h)
    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 a few times per frame; draw the trail it leaves behind."""

    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):
        # back 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
        if len(self.pts) > KEEP:
            self.pts = self.pts[-KEEP:]      # forget the oldest hops
        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                     # off the glass -- skip it
            r, g, b = rainbow(base + idx * 0.0006)    # neighbours, kin hues
            ctx.rgba(r, g, b, 0.25 + 0.75 * (idx / n))  # newest spots glow
            ctx.rectangle(x, y, DOT, DOT).fill()
        ctx.restore()


__app_export__ = Hopalong

# ------------------------------ try this --------------------------------------
# - drag or tap the first preset's 0.35 up towards 1.0 in tiny steps and watch the
#   creature melt and re-grow a new shape at every stop
# - set SPIN to 0.0 and ZOOM to 12.0 for a still, close-up portrait
# - add a preset: put (1.1, 0.5, 1.0, 2.5) at the end of PRESETS, then press
#   RIGHT until it comes up -- it stays almost entirely on the glass
```

---

## 8 · matrixrain

**Matrix rain, round-cropped** · [play with it live](https://protogon.codemyriad.io/editor/#matrixrain)

```python
# matrixrain (8) -- Matrix rain, round-cropped. Glyph columns fall with bright heads
# and fading tails; RIGHT cycles the colour.
#
# HOW IT WORKS
#   Each column is one raindrop made of letters: a bright head that falls at
#   its own speed, towing a tail of glyphs that fade the further back they
#   sit. Every frame each column answers three questions: how far did I fall?
#   (speed x time) -- do I flicker a letter? (a dice roll) -- am I off the
#   bottom? (then restart above the top with a fresh speed and tail length).
#   Letters that would land outside the round glass are simply not drawn.
#
# BUTTONS   RIGHT/LEFT cycle the 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
COLS     = 15     # 0<n<32   columns of rain ........ try 8 (sparse) or 22 (dense)
CELL     = 15     # 4<n<40   glyph size AND row spacing, px .. chunky rain: 24
FALL_MIN = 45.0   # 0<n<200  slowest column, px per second
FALL_MAX = 130.0  # 0<n<400  fastest column ......... storm: 300.0
TAIL_MIN = 6      # 0<n<40   shortest tail, in glyphs
TAIL_MAX = 16     # 0<n<40   longest tail ........... long streamers: 24
FLICKER  = 0.15   # 0<n<1    chance per frame a column swaps a letter .. boiling: 0.9
TINTS = ((0.3, 1.0, 0.4),   # phosphor green   <- RIGHT cycles these four
         (1.0, 0.7, 0.1),   # amber terminal
         (0.3, 0.9, 1.0),   # ice cyan
         (0.8, 0.4, 1.0))   # violet

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):
    # one raindrop: where it is, how fast it falls, and the letters it tows
    return {"x": x,
            "y": head_y,
            "speed": random.uniform(FALL_MIN, FALL_MAX),
            "tail": random.randint(TAIL_MIN, TAIL_MAX),
            "glyphs": [random.choice(GLYPHS) for _ in range(20)]}


class Matrixrain(app.App):
    """Move every column down a little, flicker a letter, 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
        # one column per slot across the screen, each starting 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"]):
            b.clear()
            self.tint = (self.tint + 1) % len(TINTS)
        for col in self.cols:
            col["y"] += col["speed"] * dt
            if random.random() < FLICKER:            # swap one letter, anywhere
                col["glyphs"][random.randint(0, 19)] = random.choice(GLYPHS)
            if col["y"] - col["tail"] * CELL > 128:  # 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(0.9, 1.0, 0.9)           # the head glows near-white
                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,
                             0.25 + 0.75 * fade)
                ctx.move_to(x, gy).text(col["glyphs"][k % 20])
        ctx.restore()


__app_export__ = Matrixrain

# ------------------------------ try this --------------------------------------
# - set GLYPHS = "01" for binary rain, or spell something: "EMF2026 "
# - drag or tap FLICKER to 0.9 and the letters boil; at 0.0 each column's text freezes
# - CELL = 24 with COLS = 9 makes chunky billboard rain (the font follows CELL)
```

---

## 9 · plasma

**Plasma tiles** · [play with it live](https://protogon.codemyriad.io/editor/#plasma)

```python
# plasma (9) -- Plasma tiles. Four drifting sine waves add up to paint a 16x16
# tile grid -- the classic demoscene plasma, no per-pixel framebuffer needed.
#
# HOW IT WORKS
#   Every frame, each tile asks one question: "how hot am I right now?"
#   The answer is four sine waves added together: one runs across the
#   screen, one runs down, one slants diagonally, one ripples out from the
#   centre. Where crests pile up a tile glows hot; where waves cancel it
#   goes cold. A palette turns hot-and-cold into colour -- that's plasma.
#
# BUTTONS   LEFT/RIGHT swap 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
GRID      = 16               # 4<n<24  tiles per side .. try 8 (chunky); 24 chugs the badge
SPEED     = 1.0              # 0<n<5   animation speed ......... 0.3 lava lamp, 3.0 boiling
WAVE_X    = 0.6              # 0<n<2   across-the-screen wave .. drag slowly: bands stretch
WAVE_Y    = 0.7              # 0<n<2   down-the-screen wave .... try 0.0 -- it goes flat
WAVE_DIAG = 0.45             # 0<n<2   diagonal wave ........... try 1.5: slanted stripes
RIPPLE    = 0.7              # 0<n<3   rings from the centre ... try 2.0: tight bullseye
OCEAN     = (0.0, 0.3, 0.5)  # deepest water in the ocean palette (press RIGHT) -- tap the swatch

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/fy = how far this tile sits from the centre of the grid.

def heat(fx, fy, t):
    # four waves, each wandering at its own pace
    # (the 1.1 / 0.7 / 1.7 are those paces -- fun to drag)
    across = math.sin(fx * WAVE_X + t)
    down = math.sin(fy * WAVE_Y - t * 1.1)
    diag = math.sin((fx + fy) * WAVE_DIAG + t * 0.7)
    rings = math.sin(((fx * fx + fy * fy) ** 0.5) * RIPPLE - t * 1.7)
    # each wave is -1..1, so the sum is -4..4 -> squeeze it into 0..1
    return (across + down + diag + rings + 4.0) / 8.0

# ----------------------------- the palettes ----------------------------------
# A palette turns heat (0 = coldest, 1 = hottest) into a colour. Add your own.

def fire(v):
    # embers: red wakes up first, green joins later (making orange), blue barely
    return (v * 1.6, v * 1.4 - 0.4, v * 0.6 - 0.4)

def ocean(v):
    # deep water rising to pale foam; OCEAN above sets the darkest shade
    return (OCEAN[0] + v * 0.2, OCEAN[1] + v * 0.6, OCEAN[2] + v * 0.5)

def rainbow(v):
    # three sines a third of a turn apart walk the whole colour wheel
    a = v * 6.28318
    return (0.5 + 0.5 * math.sin(a),
            0.5 + 0.5 * math.sin(a + 2.09),
            0.5 + 0.5 * math.sin(a + 4.19))

PALETTES = (fire, ocean, rainbow)


class Plasma(app.App):
    """Keep time, listen for palette swaps, paint the tile grid."""

    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.pal = 0           # which palette is live

    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"]):
            b.clear()
            self.pal = (self.pal + 1) % len(PALETTES)
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        palette = PALETTES[self.pal % len(PALETTES)]
        t = self.t
        for gy in range(GRID):
            y = -120 + gy * TILE
            fy = gy - CENTRE
            for gx in range(GRID):
                r, g, bl = palette(heat(gx - CENTRE, fy, t))
                # clamp to 0..1 -- fire runs past the ends on purpose,
                # and real badge glass does strange things beyond 1.0
                ctx.rgb(max(0.0, min(1.0, r)),
                        max(0.0, min(1.0, g)),
                        max(0.0, min(1.0, bl)))
                # +0.6 makes each tile overlap a hair, hiding hairline seams
                ctx.rectangle(-120 + gx * TILE, y, TILE + 0.6, TILE + 0.6).fill()
        ctx.restore()


__app_export__ = Plasma

# ------------------------------ try this --------------------------------------
# - set WAVE_X, WAVE_Y and WAVE_DIAG all to 0.0: the sliding waves go flat and
#   a pure bullseye is left pulsing out of the centre
# - in heat(), drag or tap the rings wave's 1.7 down to 0.0 -- the rings freeze in
#   place while the other three waves keep sliding through them
# - add a palette:  def mint(v): return (v * 0.3, v, v * 0.7)
#   then put it in PALETTES and press RIGHT until the screen turns minty
```

---

## 10 · ledring

**LED / display phase-lock** · [play with it live](https://protogon.codemyriad.io/editor/#ledring)

```python
# ledring (10) -- LED / display phase-lock. Twelve dots on the glass sit at the
# same angles as the 12 bezel LEDs; one shared phase animates both in lockstep.
#
# HOW IT WORKS
#   The badge has 12 LEDs around its rim. Each frame we keep ONE clock (the
#   phase), turn it into 12 colours, and send every colour to two places at
#   once: the real LED, and a dot drawn on screen at that LED's exact angle.
#   Same numbers in, same light out -- glass and bezel can never drift apart.
#   (A system service owns the LEDs; we borrow the ring and return it on exit.)
#
# BUTTONS   LEFT/RIGHT cycle comet -> pulse -> rainbow - 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
SPEED = 1.6                 # -6<n<6   comet speed, LEDs per second .... race it: 5.0
TAIL = 3.5                  # 0<n<16   LEDs the tail fades over . try 1.5 (spark) or 6.0
RING = 88                   # 0<n<120  radius of the on-screen ring .... pull it in: 60
DOT_MIN = 9                 # 0<n<20   dot size when its LED is dark ... try 4
DOT_GROW = 6                # 0<n<20   extra size at full brightness ... try 14 (blobby)
PULSE_RATE = 3.0            # 0<n<6    heartbeats, roughly per second .. calm it: 1.0
PULSE_TWIST = 0.0           # 0<n<2    pulse phase twist per LED .. try 0.5 (chasing wave)
SPIN = 0.2                  # -1<n<1   rainbow turns per second .. try -0.2 to reverse
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 colour wheel: red -> yellow -> green -> blue -> red
    h = h - int(h)
    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 of the badge; each next one is 1/12 turn clockwise
    return -math.pi / 2 + i * (TAU / 12)


class Ledring(app.App):
    """One phase, twelve colours, two outputs: the LEDs and the screen."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # have we taken the screen + LEDs yet?
        self.t = 0.0           # the shared phase: 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())   # our ring now
            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 ring back
            self.minimise()
            return False
        if b.get(BUTTON_TYPES["RIGHT"]) or b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.mode = (self.mode + 1) % 3
        return True

    def ring_colours(self):
        # turn the one phase into 12 (r,g,b) 0..1 colours + where the head is
        t = self.t
        head = (t * SPEED) % 12               # comet position, in LED numbers
        out = []
        for i in range(12):
            if self.mode == 0:                        # comet
                d = min((i - head) % 12, (head - i) % 12)   # ring distance
                f = max(0.0, 1.0 - d / TAIL)  # 1 at the head, 0 far behind
                out.append((f * COMET[0], f * COMET[1], f * COMET[2]))
            elif self.mode == 1:                      # pulse
                # every LED breathes together (drag the 0.0 to twist it)
                wave = math.sin(t * PULSE_RATE - i * PULSE_TWIST)
                f = 0.15 + 0.85 * (0.5 + 0.5 * wave)
                out.append((PULSE[0] * f, PULSE[1] * f, PULSE[2] * f))
            else:                                     # rainbow
                out.append(hue(i / 12.0 + t * SPIN))  # each LED 1/12 further
        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]
            # the SAME colour goes to the physical LED and to the 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       # brighter LED = bigger dot
            ctx.arc(x, y, DOT_MIN + DOT_GROW * bright, 0, TAU, True).fill()
        tildagonos.leds.write()
        if self.mode != 1:
            # a hand pointing at the comet head, so the lockstep is obvious
            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, drag or tap the 0.0 in "i * 0.0" up to 0.5: the shared heartbeat
#   unrolls into a wave chasing itself around the ring
# - set TAIL to 12.0: the whole ring glows and the comet becomes a soft tide
# - make SPEED negative (-1.6): comet, pointer and LEDs all reverse together --
#   every one of them reads the same phase
```

---

## 11 · pipes

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

```python
# pipes (11) -- Pipes grower. A head walks a hidden grid laying colourful
# pipes with random turns, like the classic screensaver. CONFIRM clears.
#
# HOW IT WORKS
#   The screen hides a grid of little cells. Each step, the pipe's head asks
#   "which neighbour cells are still empty?" and moves into one -- preferring
#   to keep going straight, which is what makes those long satisfying runs.
#   Boxed in with nowhere left to go? A new pipe starts somewhere empty, in
#   the next colour. Once most of the board is pipe, everything wipes clean
#   and the plumbing begins again.
#
# See also:  the Windows NT "3D Pipes" screensaver --
#            https://devblogs.microsoft.com/oldnewthing/20240611-00/?p=109881
#
# BUTTONS   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

# ------------------------------ tweak me -------------------------------------
# Drag a number to change it (double-click, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
GRID     = 24     # 4<n<40  cells per side .......... try 12 (with CELL 18: fat pipes)
CELL     = 9      # 4<n<40  px between cell centres .. the grid step; try 18
SPEED    = 2      # 0<n<12  cells grown per frame .... try 6 for time-lapse plumbing
STRAIGHT = 0.7    # 0<n<1   chance to keep straight .. 0.95 long runs, 0.0 scribble
FULL     = 0.72   # 0<n<1   board fraction that triggers a wipe ....... try 0.98
PIPE_W   = 5      # 0<n<20  pipe thickness in px ..... try 11 (pairs well with CELL 18)
HEAD_R   = 4      # 0<n<12  radius of the glowing white head
PIPE_COLS = ((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))

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


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


class Pipes(app.App):
    """Grow pipes cell by cell; draw the finished runs and the bright head."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False        # have we taken the screen yet?
        self.wipe_board()

    def wipe_board(self):
        self.visited = set()   # cells already claimed by a pipe
        self.segs = []         # finished pipe pieces: (x1, y1, x2, y2, colour)
        self.col = 0           # which palette entry the current pipe wears
        self.start_pipe()

    def start_pipe(self):
        # begin a fresh pipe on any still-empty cell, in the next colour
        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(PIPE_COLS)

    def grow(self):
        # one step: pick an empty neighbour (favouring straight ahead), move in
        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 it, 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()          # board is crowded -- start over

    def update(self, delta):
        if not self.fg:
            eventbus.emit(RequestForegroundPushEvent(self))
            self.fg = True
        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.wipe_board()
        for _ in range(SPEED):
            self.grow()
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        ctx.line_width = PIPE_W
        # Batch all segments of one colour into a single path and stroke once
        # (<=6 stroke() calls per frame). A full board is ~400 segments, and a
        # stroke() per segment is what stalls the real badge.
        for ci in range(len(PIPE_COLS)):
            drew = False
            for (x1, y1, x2, y2, c) in self.segs:
                if c == ci:
                    ctx.move_to(x1, y1).line_to(x2, y2)
                    drew = True
            if drew:
                ctx.rgb(*PIPE_COLS[ci])
                ctx.stroke()
        # the head: a bright white bead so you can watch it think
        hx, hy = cell_centre(self.head)
        ctx.rgb(1, 1, 1)
        ctx.arc(hx, hy, HEAD_R, 0, 6.2832, True).fill()
        ctx.restore()


__app_export__ = Pipes

# ------------------------------ try this --------------------------------------
# - drag or tap STRAIGHT to 0.0: the head coin-flips at every cell and scribbles;
#   at 0.95 it shoots long straight runs and only turns when it must
# - GRID = 12, CELL = 18, PIPE_W = 11: chunky retro plumbing, same footprint
# - FULL = 0.98: watch pipes squeeze into the last free corners before the wipe
```

---

## 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. A wrap-around grid where every cell obeys
# one tiny neighbour rule: Life, Brian's Brain, or cyclic.
#
# HOW IT WORKS
#   The grid is a tiny world: a few times a second every cell looks at its
#   eight neighbours and one rule picks its fate. LIFE: born next to exactly
#   3 live cells, survive with 2-3 (survivors slowly blush red). BRAIN: fire,
#   tire, rest -- rest ignites beside exactly 2 sparks. CYCLIC: four colours,
#   each eating the one before it. Nobody plans it; patterns grow 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   RIGHT/LEFT next rule - CONFIRM fresh random seed - 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
GRID       = 20                # 4<n<28  cells per side .... try 14; >20 chugs the badge
CELL       = 10                # 4<n<20  px per cell ....... try 11 to fill the screen
STEP_EVERY = 3                 # 0<n<16  frames per generation ..... 1 races, 8 crawls
SEED       = 0.32              # 0<n<1   fraction alive on reseed  lonely 0.1, mobbed 0.6
GAP        = 0.6               # 0<n<5   grout between cells  0.0 fuses, 3.0 makes beads
AGE_TINT   = 0.1               # 0<n<1   how fast Life survivors blush red .... try 0.5
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)

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 am I next?" step() below asks every cell, each generation.

def life(s, nbrs):
    # Conway's Game of Life: born next to exactly 3, survive on 2 or 3
    n = sum(nbrs)
    return 1 if n == 3 or (s and n == 2) else 0

def brain(s, nbrs):
    # Brian's Brain: firing (1) tires (2), tired rests (0), and a resting
    # cell ignites beside exactly 2 sparks -- endless travelling waves
    if s:
        return (s + 1) % 3
    return 1 if sum(1 for v in nbrs if v == 1) == 2 else 0

def cyclic(s, nbrs):
    # four colours chase in a circle: s is eaten by colour s+1 the moment
    # one touches it up, down, left or right (those are nbrs 1, 3, 4, 6)
    eater = (s + 1) % 4
    return eater if eater in (nbrs[1], nbrs[3], nbrs[4], nbrs[6]) else s

RULES = (life, brain, cyclic)
NAMES = ("life", "brain", "cyclic")
CYCLE_COLOURS = ((1.0, 0.3, 0.2), (0.9, 0.8, 0.2),    # cyclic's four tribes:
                 (0.2, 0.8, 0.5), (0.3, 0.4, 1.0))    # red yellow green blue


class Cellular(app.App):
    """Count frames, step the world every few, paint every living cell."""

    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()

    def reseed(self):
        # scatter a fresh random soup; every cell starts at age zero
        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)]

    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
        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"]):
            b.clear()
            self.rule = (self.rule + 1) % 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:
                    continue          # empty cells stay black and cost nothing
                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 = dim
                    ctx.rgb(c[0], c[1], c[2])
                else:
                    c = CYCLE_COLOURS[v % 4]
                    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 --------------------------------------
# - in reseed(), swap `1 if random.random() < SEED else 0` for
#   `random.randrange(4)`, then press RIGHT until "cyclic": spiral storms
# - in life(), change `n == 3` to `n in (3, 6)` -- a close cousin of
#   HighLife: the soup suddenly grows lacy, self-copying structures
# - in brain(), change `== 2` to `== 1`: every lone spark blooms into a ring
```

---

## 13 · ribbons

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

```python
# ribbons (13) -- Drift lines. Wavy anchor points threaded into smooth spline
# ribbons; a few phase-shifted copies braid together.
#
# HOW IT WORKS
#   A ribbon is really just a few invisible anchor points spread left to
#   right. Every frame each anchor rides two sine waves added together --
#   one big slow swing plus a gentler wobble -- so it drifts like seaweed.
#   Then a smooth curve is threaded through them: quad_to bends TOWARD each
#   anchor and glides through the midpoint beyond it, the classic trick for
#   a spline with no kinks. Copies with the wave shifted along braid together.
#
# See also:  in the spirit of the old "Mystify" screensaver -- https://en.wikipedia.org/wiki/Mystify
#
# BUTTONS   RIGHT/LEFT change how many ribbons braid - 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
RIBBONS    = 3      # 1<n<12   ribbons in the braid (RIGHT/LEFT change this too).. try 6
R_MIN, R_MAX = 2, 6  # 1<a<12  1<b<12  ribbon-count range RIGHT/LEFT walks .. try 1, 9
ANCHORS    = 7      # 2<n<16   anchor points per ribbon ........ try 4 (angular) or 12
SWAY       = 70.0   # 0<n<160  how far anchors swing up and down, px ........ try 110.0
SWAY_SPEED = 0.8    # 0<n<4    speed of the big swing ............... try 2.0 (frantic)
WOBBLE     = 20.0   # 0<n<80   the slower second wave stacked on top ........ try 45.0
THICKNESS  = 2.5    # 0<n<12   ribbon stroke width, px ............... try 6.0 (chunky)
GLOW       = 0.85   # 0<n<1    ribbon opacity, 0..1 .................. try 0.40 (misty)
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)   # px between anchors -- fills the screen

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

def anchor_points(t, phase):
    # Where are this ribbon's anchor points right now?
    pts = []
    for i in range(ANCHORS):
        # 0.9 = how much the wave twists between neighbouring anchors
        y = (math.sin(t * SWAY_SPEED + i * 0.9 + phase) * SWAY
             + math.sin(t * 0.35 + i) * WOBBLE)
        # plus a little sideways sway (12 px) so the spacing breathes too
        x = -108 + i * SPAN + math.cos(t * 0.5 + i * 1.3 + phase) * 12
        pts.append((x, y))
    return pts


class Ribbons(app.App):
    """Keep time, listen to buttons, drift the anchors, thread the ribbons."""

    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 += 2    # striding by 2 still visits every count 2..6
        if b.get(BUTTON_TYPES["LEFT"]):
            b.clear()
            self.shift -= 2
        return True

    def draw(self, ctx):
        ctx.save()
        ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
        # the button offset wraps so the braid always has R_MIN..R_MAX ribbons
        count = R_MIN + (RIBBONS - R_MIN + self.shift) % (R_MAX - R_MIN + 1)
        ctx.line_width = THICKNESS
        for r in range(count):
            phase = r * (6.28318 / count)   # spread the copies round 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, 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 --------------------------------------
# - drag or tap SWAY to 110.0 and THICKNESS to 6.0 -- fat ribbons that fill the badge
# - set WOBBLE to 0.0 for one clean repeating braid, then ease it back up and
#   watch the pattern loosen into drift
# - the 0.9 in anchor_points() is the twist between neighbours: try 0.1 (lazy
#   arcs) or 3.0 (scribbles)
```

---

## 14 · metaballs

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

```python
# metaballs (14) -- Orbiting metaballs (lite). Glowing blobs circle the centre
# and seem to melt together wherever their halos overlap.
#
# HOW IT WORKS
#   Real metaballs measure a "goo field" at every pixel -- far too slow here.
#   The trick: each blob is just a stack of see-through discs, small and
#   bright in the middle, big and faint at the edge. Translucent colour piles
#   up where discs overlap, so when two blobs drift close their halos build a
#   bright bridge between them -- your eye reads it as goo merging.
#   Each blob follows its own slightly detuned orbit, so they keep meeting.
#
# Credits:   metaballs, invented by Jim Blinn for Carl Sagan's Cosmos --
#            https://en.wikipedia.org/wiki/Metaballs
#
# BUTTONS   RIGHT/LEFT change 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, or tap on a phone, for a slider);
# the "MIN<n<MAX" notes set each slider's range.
SPEED   = 1.0    # 0<n<5    pace of the whole dance ...... try 0.3 (lava lamp) or 2.5
ORBIT   = 40     # 0<n<120  average orbit radius, px ..... try 70 (blobs hug the rim)
SWAY    = 18     # 0<n<80   how far orbits breathe .......... try 60 (wild dives)
SIZE    = 46     # 0<n<100  blob radius, px ................. try 65 (one big glob)
LAYERS  = 5      # 1<n<12   discs per blob .................. try 2 (flat) or 8 (silky)
HAZE    = 0.12   # 0<n<1    alpha of the outermost halo ..... try 0.30 (foggy)
GLOW    = 0.26   # 0<n<1    extra alpha at the core ......... try 0.45 (hot centres)
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 the stack core-first: each later disc is bigger and fainter,
    # so the blob fades out smoothly instead of ending at 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):
    """Move each blob along its wobbly orbit, then paint the disc stacks."""

    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.count = 3         # blobs on screen (RIGHT/LEFT cycle 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 % 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):
            # spread the blobs evenly round the circle to start with...
            start = k * (TAU / self.count)
            # ...then let each orbit breathe in and out at its own moment
            orbit = ORBIT + SWAY * math.sin(t * 0.5 + k)
            # 0.12 and 0.1 detune each blob's tempo -- drag them toward 0
            # and the blobs fall into formation; bigger and they scatter
            x = math.cos(t * (0.6 + 0.12 * k) + start) * orbit
            y = math.sin(t * (0.5 + 0.1 * k) + start) * orbit * 0.9
            draw_blob(ctx, x, y, PALETTE[k % len(PALETTE)])
        ctx.restore()


__app_export__ = Metaballs

# ------------------------------ try this --------------------------------------
# - drag or tap SWAY up to 60: the blobs dive right through the middle and pile
#   into one white-hot blaze every time they cross
# - set LAYERS to 1 -- suddenly it's just flat circles. The whole metaball
#   illusion lives in that stack of fading discs
# - drag or tap the 0.9 at the end of the y line down to 0.3 and the dance
#   flattens into a shallow band, like blobs on a horizon
```

---

## 15 · timescope

**A multiplication circle** · [play with it live](https://protogon.codemyriad.io/editor/#timescope)

```python
# timescope (15) -- A multiplication circle. One number slowly changes; the
# straight chords inside the circle fold into cardioids, flowers and knots.
#
# HOW IT WORKS
#   Put N pins around a clock face. For each pin i, draw a line from i to
#   i * MULTIPLIER, wrapping around when it passes N. When MULTIPLIER is 2,
#   this is the 2-times table; 3 is the 3-times table. Sliding between them
#   makes the familiar maths shape-bend into something that feels alive.
#
# BUTTONS   LEFT/RIGHT jump table - UP/DOWN speed - CONFIRM dot 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 -------------------------------------
DOTS    = (72, 108, 144)          # pins around the circle; 144 is still badge-kind
SPEEDS  = (0.0, 0.16, 0.36, 0.72) # multiplier change per second; 0.0 = pause
RADIUS  = 104.0                   # chord endpoints sit just inside the round crop
ECHOES  = (0.18, 0.07, 0.0)       # draw older multipliers too: instant motion trail
TAU     = 6.2831853


def hue(h):
    # 0..1 around the colour wheel: red -> yellow -> green -> blue -> red
    h = (h % 1.0) * 6.0
    i = int(h)
    f = h - i
    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]


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 mirror it on the 12 LEDs."""

    def __init__(self, config=None):
        super().__init__()
        self.button_states = Buttons(self)
        self.fg = False
        self.t = 0.0              # added to the multiplier
        self.shift = 0.0          # button jumps between exact integer tables
        self.dot_mode = 1         # DOTS[1] = 108
        self.speed_mode = 1       # SPEEDS[1] = slow drift
        self.rebuild()

    def rebuild(self):
        n = DOTS[self.dot_mode]
        self.pins = [point(i / n) for i in range(n)]
        self.dot_step = max(1, n // 72)  # draw at most 72 little rim dots

    def multiplier(self):
        return 2.0 + 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

        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 * 0.04)
            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 * 0.03)
            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)
            self.draw_chords(ctx, m - lag, 0.09 + 0.32 * age * age, 0.55 + age)

        for i in range(0, len(self.pins), self.dot_step):
            r, g, b = hue(i / len(self.pins) + m * 0.03)
            ctx.rgba(r, g, b, 0.85)
            ctx.arc(self.pins[i][0], self.pins[i][1], 1.7, 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 speed is zero, then RIGHT one click at a time: 2x, 3x, 4x...
# - set DOTS to (12, 24, 144): the first two make the "clock face" idea obvious
# - remove two entries from ECHOES for a clean maths-diagram look
```

