BUSY.Tedt.org BUSY Bar Apps
← All Apps

Matrix Digital Rain

Matrix-style digital rain animation for BUSY Bar, with configurable character size, speed, density, and color theme.

Updated Aug 28, 2026 · Added Aug 11, 2026 · ↓ 0 package download starts

By robynhub · ▲ 0 · Rate and Discuss

How It Works

Matrix-style digital rain animation for BUSY Bar, with configurable character size, speed, density, and color theme.

Download and extract the complete app folder. Keep its files together; some apps include additional modules, assets, or configuration examples.

Review app.py and any configuration examples for this app’s options, then run it from the extracted folder:

python3 app.py

Connect BUSY Bar over USB, or use the connection options supported by this app. See the official HTTP API documentation for device setup.

Source Files

These are the mirrored source bytes. The complete package also includes every asset and supporting file.

app.py
#!/usr/bin/env python3
"""Matrix Digital Rain for BUSY Bar.

Classic green digital-rain animation for the 72x16 BUSY Bar display.
Each frame is rasterized locally and pushed as a single PNG image element,
which keeps animation smooth and avoids the display element-count limit.

    python3 matrix_digital_rain.py
    python3 matrix_digital_rain.py --host 127.0.0.1:8080
    python3 matrix_digital_rain.py --fps 12 --speed 1.2 --density 0.75
    python3 matrix_digital_rain.py --theme cyan
    python3 matrix_digital_rain.py --test

No external Python dependencies are required.
"""

import argparse
import json
import random
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib

APP = "matrix-digital-rain"
W, H = 72, 16

# Ultra-compact 2x3 pixel glyphs. At 72x16 this gives the rain enough
# columns and vertical character cells to look dense rather than oversized.
FONT = {
    "0": ["11", "11", "11"], "1": ["01", "01", "01"],
    "2": ["11", "01", "10"], "3": ["11", "01", "11"],
    "4": ["10", "11", "01"], "5": ["11", "10", "01"],
    "6": ["10", "11", "11"], "7": ["11", "01", "01"],
    "8": ["11", "11", "11"], "9": ["11", "11", "01"],
    "A": ["11", "11", "10"], "C": ["11", "10", "11"],
    "E": ["11", "11", "10"], "F": ["11", "11", "10"],
    "H": ["10", "11", "10"], "J": ["01", "01", "11"],
    "N": ["11", "11", "11"], "P": ["11", "11", "10"],
    "S": ["11", "10", "01"], "T": ["11", "01", "01"],
    "U": ["10", "10", "11"], "X": ["10", "01", "10"],
    "Y": ["10", "11", "01"], "Z": ["11", "01", "10"],
    "+": ["00", "11", "01"], "-": ["00", "11", "00"],
    "#": ["11", "11", "11"], "*": ["10", "01", "10"],
}

SMALL_FONT = FONT
BIG_FONT = {
    "0": ["111","101","101","101","111"], "1": ["010","110","010","010","111"],
    "2": ["111","001","111","100","111"], "3": ["111","001","111","001","111"],
    "4": ["101","101","111","001","001"], "5": ["111","100","111","001","111"],
    "6": ["111","100","111","101","111"], "7": ["111","001","010","010","010"],
    "8": ["111","101","111","101","111"], "9": ["111","101","111","001","111"],
    "A": ["010","101","111","101","101"], "C": ["111","100","100","100","111"],
    "E": ["111","100","110","100","111"], "F": ["111","100","110","100","100"],
    "H": ["101","101","111","101","101"], "J": ["001","001","001","101","111"],
    "N": ["101","111","111","111","101"], "P": ["110","101","110","100","100"],
    "S": ["111","100","111","001","111"], "T": ["111","010","010","010","010"],
    "U": ["101","101","101","101","111"], "X": ["101","101","010","101","101"],
    "Y": ["101","101","010","010","010"], "Z": ["111","001","010","100","111"],
    "+": ["000","010","111","010","000"], "-": ["000","000","111","000","000"],
    "#": ["101","111","101","111","101"], "*": ["000","101","010","101","000"],
}
FONT = SMALL_FONT
CHARS = list(SMALL_FONT)

THEMES = {
    "green": {
        "head": (220, 255, 220),
        "bright": (60, 255, 90),
        "mid": (0, 170, 45),
        "dim": (0, 70, 18),
    },
    "cyan": {
        "head": (220, 255, 255),
        "bright": (60, 255, 255),
        "mid": (0, 170, 190),
        "dim": (0, 60, 70),
    },
    "amber": {
        "head": (255, 245, 210),
        "bright": (255, 180, 35),
        "mid": (180, 90, 0),
        "dim": (70, 30, 0),
    },
    "violet": {
        "head": (220, 205, 255),
        "bright": (130, 70, 255),
        "mid": (75, 30, 160),
        "dim": (28, 8, 65),
    },
}


def _base(host):
    host = host.replace("http://", "").replace("https://", "").rstrip("/")
    return "http://" + host


def _png(pixels):
    """Encode a flat 72x16 RGB buffer as a minimal RGBA PNG using stdlib only."""
    raw = bytearray()
    for y in range(H):
        raw.append(0)
        base = y * W
        for x in range(W):
            r, g, b = pixels[base + x]
            raw += bytes((r, g, b, 255))

    def chunk(tag, data):
        blob = tag + data
        return (struct.pack(">I", len(data)) + blob +
                struct.pack(">I", zlib.crc32(blob) & 0xFFFFFFFF))

    return (b"\x89PNG\r\n\x1a\n"
            + chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
            + chunk(b"IDAT", zlib.compress(bytes(raw), 6))
            + chunk(b"IEND", b""))


_RING = 4
_frame_no = 0


def _post(host, path, data, content_type):
    req = urllib.request.Request(
        _base(host) + path,
        data=data,
        method="POST",
        headers={"Content-Type": content_type},
    )
    with urllib.request.urlopen(req, timeout=5) as resp:
        return resp.getcode()


def show(host, pixels):
    """Upload and draw one full-screen frame."""
    global _frame_no
    filename = "frame%d.png" % (_frame_no % _RING)
    _frame_no += 1

    try:
        _post(
            host,
            "/api/assets/upload?application_name=%s&file=%s" % (APP, filename),
            _png(pixels),
            "application/octet-stream",
        )
        body = {
            "application_name": APP,
            "elements": [
                {"id": "frame", "type": "image", "path": filename, "x": 0, "y": 0}
            ],
        }
        return _post(host, "/api/display/draw", json.dumps(body).encode(), "application/json")
    except urllib.error.HTTPError as exc:
        if exc.code == 409:
            return 409
        raise


def clear(host):
    qs = urllib.parse.urlencode({"application_name": APP})
    req = urllib.request.Request(_base(host) + "/api/display/draw?" + qs, method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except urllib.error.URLError:
        pass


def blank():
    return [(0, 0, 0)] * (W * H)


def px(buf, x, y, color):
    if 0 <= x < W and 0 <= y < H:
        buf[y * W + x] = color


def mix(a, b, t):
    return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))


def draw_glyph(buf, x, y, char, color, font):
    glyph = font.get(char)
    if not glyph:
        return
    for gy, row in enumerate(glyph):
        for gx, bit in enumerate(row):
            if bit == "1":
                px(buf, x + gx, y + gy, color)


class Drop:
    """One falling rain column rendered in a 4x6 logical character cell."""

    def __init__(self, col, density, speed, char_size):
        self.col = col
        self.density = density
        self.base_speed = speed
        self.font = SMALL_FONT if char_size == "small" else BIG_FONT
        self.cell_w = 3 if char_size == "small" else 4
        self.cell_h = 4 if char_size == "small" else 6
        self.reset(initial=True)

    def reset(self, initial=False):
        self.length = random.randint(2, 6)
        self.row = random.uniform(-8.0, 2.0 if initial else -1.0)
        self.speed = random.uniform(0.55, 1.35) * self.base_speed
        self.active = random.random() < self.density
        self.chars = [random.choice(CHARS) for _ in range(self.length + 2)]
        self.mutate_clock = random.randint(1, 4)

    def update(self, dt):
        if not self.active:
            if random.random() < min(1.0, dt * 0.18 * self.density):
                self.active = True
                self.row = random.uniform(-4.0, -1.0)
            return

        self.row += self.speed * dt * 7.5
        self.mutate_clock -= 1
        if self.mutate_clock <= 0:
            self.mutate_clock = random.randint(1, 4)
            if self.chars:
                self.chars[random.randrange(len(self.chars))] = random.choice(CHARS)

        if self.row - self.length > 4.0:
            self.reset()

    def draw(self, buf, theme):
        if not self.active:
            return

        x = self.col * self.cell_w
        head_row = int(self.row)

        for i in range(self.length):
            logical_row = head_row - i
            y = logical_row * self.cell_h - 1
            if y >= H or y + 3 < 0:
                continue

            if i == 0:
                color = theme["head"]
            elif i == 1:
                color = theme["bright"]
            else:
                t = (i - 2) / max(1, self.length - 2)
                color = mix(theme["mid"], theme["dim"], min(1.0, t))

            char = self.chars[i % len(self.chars)]
            draw_glyph(buf, x, y, char, color, self.font)


class MatrixRain:
    def __init__(self, density=0.75, speed=1.0, theme="green", char_size="small"):
        self.theme = THEMES[theme]
        cell_w = 3 if char_size == "small" else 4
        self.cols = W // cell_w
        self.drops = [Drop(c, density, speed, char_size) for c in range(self.cols)]

    def update(self, dt):
        for drop in self.drops:
            drop.update(dt)

    def frame(self):
        buf = blank()
        for drop in self.drops:
            drop.draw(buf, self.theme)
        return buf


def parse_args():
    p = argparse.ArgumentParser(description="Matrix Digital Rain for BUSY Bar")
    p.add_argument("--host", default="10.0.4.20", help="BUSY Bar host (default: 10.0.4.20)")
    p.add_argument("--fps", type=float, default=12.0, help="frames per second (default: 12)")
    p.add_argument("--speed", type=float, default=1.0, help="fall speed multiplier (default: 1.0)")
    p.add_argument("--density", type=float, default=0.78, help="active column density 0..1 (default: 0.78)")
    p.add_argument("--theme", choices=sorted(THEMES), default="green", help="color theme")
    p.add_argument("--char-size", choices=["small", "big"], default="small",
                   help="character size: small (2x3) or big (3x5), default: small")
    p.add_argument("--seed", type=int, default=None, help="random seed for reproducible animation")
    p.add_argument("--test", action="store_true", help="draw one frame and exit")
    return p.parse_args()


def main():
    args = parse_args()
    args.fps = max(1.0, min(30.0, args.fps))
    args.speed = max(0.1, min(4.0, args.speed))
    args.density = max(0.05, min(1.0, args.density))

    if args.seed is not None:
        random.seed(args.seed)

    rain = MatrixRain(args.density, args.speed, args.theme, args.char_size)

    if args.test:
        for _ in range(8):
            rain.update(0.10)
        status = show(args.host, rain.frame())
        print(f"test: drew one frame ({args.theme}) status {status}")
        return

    print(f"matrix-digital-rain -> {_base(args.host)}  (Ctrl-C to stop)")
    interval = 1.0 / args.fps
    last = time.monotonic()

    try:
        while True:
            start = time.monotonic()
            now = start
            dt = min(0.25, now - last)
            last = now

            rain.update(dt)
            show(args.host, rain.frame())

            elapsed = time.monotonic() - start
            if elapsed < interval:
                time.sleep(interval - elapsed)
    except KeyboardInterrupt:
        print("\nstopped.")
    except urllib.error.HTTPError as exc:
        sys.exit(f"error: HTTP {exc.code} - {exc.read().decode('utf-8', 'ignore')}")
    except urllib.error.URLError as exc:
        sys.exit(f"error: cannot reach {_base(args.host)} - {exc.reason}")
    finally:
        clear(args.host)


if __name__ == "__main__":
    main()
manifest.yaml
name: Matrix Digital Rain
author: robynhub
description: Matrix-style digital rain animation for BUSY Bar, with configurable character size, speed, density, and color theme.
tags:
  - animation
  - ambient
  - matrix
preview: ./preview.gif

Source and Provenance

View This Version on GitHub →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: ef97f40f43d152fa444f285e103ec9dc0bb55f98acac7f07789e78f1e0929988

The Community Workbench

Rate and Discuss

Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.