BUSY.Tedt.org BUSY Bar Apps
← All Apps

Pixel Fire

Demoscene pixel effects - fire, matrix rain, and plasma.

Updated Aug 16, 2026 · Added Jul 23, 2026 · ↓ 0 package download starts

By maxswinkels · ▲ 0 · Rate and Discuss

How It Works

Demoscene pixel effects - fire, matrix rain, and plasma.

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
"""Demoscene pixel effects: fire, matrix rain, plasma.

    python app.py [fire|rain|plasma]     # BUSY Bar over USB (always 10.0.4.20)
    python app.py --host 127.0.0.1:8080  # emulator or a Wi-Fi bar

Each frame is rendered to a single 72x16 image and pushed to the bar as one
image element. Drawing hundreds of individual rectangles costs ~3.6 ms each on
the device, so a full frame of rects only manages 3-6 fps; a full-frame image
is a flat ~50 ms regardless of how busy the picture is, so every effect runs
smoothly at ~18 fps.
"""
import json
import math
import random
import struct
import sys
import time
import urllib.error
import urllib.request
import zlib

APP = "pixel-fire"
W, H = 72, 16

# ---------------------------------------------------------------------------
# BUSY Bar HTTP API — self-contained, stdlib only.
# Over USB the bar is always at 10.0.4.20; --host targets a Wi-Fi bar or the
# emulator. Full API docs are served by the device: http://10.0.4.20/docs
# ---------------------------------------------------------------------------

def _host(default="10.0.4.20"):
    if "--host" in sys.argv:
        i = sys.argv.index("--host")
        if i + 1 < len(sys.argv):
            return sys.argv[i + 1]
    return default

BASE = "http://" + _host().replace("http://", "").rstrip("/")


def _post(path, data, content_type):
    req = urllib.request.Request(BASE + path, data=data, method="POST",
                                 headers={"Content-Type": content_type})
    with urllib.request.urlopen(req, timeout=5):
        pass


def _clear():
    req = urllib.request.Request(
        BASE + "/api/display/draw?application_name=" + APP, method="DELETE")
    try:
        with urllib.request.urlopen(req, timeout=5):
            pass
    except urllib.error.URLError:
        pass


def _png(pixels):
    """72x16 flat list of (r, g, b) -> minimal RGBA PNG bytes (stdlib only)."""
    raw = bytearray()
    for y in range(H):
        raw.append(0)  # filter type 0 (none) per scanline
        for x in range(W):
            r, g, b = pixels[y * W + x]
            raw += bytes((r, g, b, 255))

    def _chunk(tag, data):
        c = tag + data
        return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 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""))


# The bar renders one uploaded image far faster than many rect elements, but it
# briefly locks an asset while a draw reads it. Uploading over that same name
# too soon returns HTTP 508, so we rotate through a few filenames.
_RING = 4
_frame = 0


def show(pixels):
    """Push one full-screen frame: upload the PNG, draw it as one image."""
    global _frame
    fn = "frame%d.png" % (_frame % _RING)
    _frame += 1
    _post("/api/assets/upload?application_name=%s&file=%s" % (APP, fn),
          _png(pixels), "application/octet-stream")
    body = {"application_name": APP,
            "elements": [{"id": "frame", "type": "image", "path": fn, "x": 0, "y": 0}]}
    try:
        _post("/api/display/draw", json.dumps(body).encode(), "application/json")
    except urllib.error.HTTPError as e:
        if e.code != 409:  # 409 = a higher-priority app owns the display
            raise


def _flatten(buf, palette):
    """2D index buffer + rgb palette -> flat list of (r, g, b), row-major."""
    px = []
    for y in range(H):
        row = buf[y]
        for x in range(W):
            px.append(palette[row[x]])
    return px

# ---------------------------------------------------------------------------
# Palettes (r, g, b). Index 0 is the background (off / black) for fire and rain;
# plasma fills the whole frame so it indexes its palette directly.
# ---------------------------------------------------------------------------

FIRE_PALETTE = [
    (0x00, 0x00, 0x00),  # 0 background (off)
    (0x3C, 0x00, 0x00),  # 1 near-black red
    (0x82, 0x10, 0x00),  # 2 dark red
    (0xC8, 0x32, 0x00),  # 3 red
    (0xFF, 0x64, 0x00),  # 4 orange-red
    (0xFF, 0xA0, 0x28),  # 5 orange
    (0xFF, 0xE0, 0x60),  # 6 yellow
    (0xFF, 0xF8, 0xC8),  # 7 near-white
]

PLASMA_PALETTE = [
    (0x0A, 0x00, 0x50),  # 0 deep blue
    (0x3A, 0x00, 0x80),  # 1 indigo
    (0x70, 0x00, 0xA0),  # 2 purple
    (0xB0, 0x00, 0x6A),  # 3 magenta
    (0xD0, 0x40, 0x00),  # 4 orange-red
    (0xE0, 0x80, 0x00),  # 5 amber
    (0xFF, 0xD0, 0x00),  # 6 yellow
]

RAIN_HEAD = (0x66, 0xCC, 0xFF)
RAIN_TRAIL = [(0x33, 0x88, 0xEE), (0x22, 0x55, 0xBB), (0x11, 0x2E, 0x66)]

# ---------------------------------------------------------------------------
# Effect selection
# ---------------------------------------------------------------------------

def _effect_arg():
    valid = {"fire", "rain", "plasma"}
    skip_next = False
    for a in sys.argv[1:]:
        if skip_next:
            skip_next = False
            continue
        if a == "--host":
            skip_next = True
            continue
        if a in valid:
            return a
    return "fire"

EFFECT = _effect_arg()

# ---------------------------------------------------------------------------
# Fire effect
# ---------------------------------------------------------------------------

_heat = [[0] * W for _ in range(H + 1)]  # row H = heat source
_heat_prev = [[0] * W for _ in range(H)]  # temporal blend buffer


def _tick_fire():
    # Reseed the source row fully each frame: pure random flicker
    for x in range(W):
        _heat[H][x] = random.randint(120, 235)

    # Propagate upward (row H-1 down to 0)
    new_heat = [[0] * W for _ in range(H)]
    for y in range(H - 1, -1, -1):
        for x in range(W):
            xl = max(0, x - 1)
            xr = min(W - 1, x + 1)
            avg = (_heat[y + 1][xl] + _heat[y + 1][x] + _heat[y + 1][xr]
                   + (_heat[y + 1][x] if y + 2 > H else _heat[min(H, y + 2)][x])) // 4
            decay = random.randint(4, 24)
            new_heat[y][x] = max(0, avg - decay)

    # Temporal blend: 66% old, 34% new → slows the flicker without waves
    for y in range(H):
        for x in range(W):
            _heat[y][x] = (2 * _heat_prev[y][x] + new_heat[y][x]) // 3
        # Update prev for next frame
        _heat_prev[y] = list(_heat[y])

    # Map heat → palette index (7 steps + 0=black). No horizontal smoothing is
    # needed now that each frame is one image (rect count no longer matters).
    n = len(FIRE_PALETTE) - 1  # 7
    buf = [[0] * W for _ in range(H)]
    for y in range(H):
        for x in range(W):
            h = _heat[y][x]
            buf[y][x] = 0 if h < 24 else max(1, min(n, 1 + (h * n) // 256))

    return _flatten(buf, FIRE_PALETTE)

# ---------------------------------------------------------------------------
# Rain (matrix) effect
# ---------------------------------------------------------------------------

_drops = []


def _init_rain():
    global _drops
    _drops = []
    for _ in range(20):
        _drops.append({
            "col": random.randint(0, W - 1),
            "y": random.randint(-4, H - 1),
            "speed": random.uniform(0.6, 1.4),
        })


def _tick_rain():
    # per-cell index: 0=off, 1..3 = trail (dim→bright), 4 = head
    palette = [(0x00, 0x00, 0x00)] + RAIN_TRAIL + [RAIN_HEAD]
    TRAIL_LEN = len(RAIN_TRAIL)
    HEAD_IDX = len(palette) - 1

    buf = [[0] * W for _ in range(H)]
    for drop in _drops:
        drop["y"] += drop["speed"]
        hy = int(drop["y"])
        cx = drop["col"]
        if 0 <= hy < H:
            buf[hy][cx] = HEAD_IDX
        for t, color_idx in enumerate(range(1, HEAD_IDX)):
            ty = hy - 1 - t
            if 0 <= ty < H:
                buf[ty][cx] = color_idx
        # respawn once fully off the bottom
        if hy - TRAIL_LEN > H:
            drop["col"] = random.randint(0, W - 1)
            drop["y"] = random.uniform(-6, -1)
            drop["speed"] = random.uniform(0.6, 1.4)

    return _flatten(buf, palette)

# ---------------------------------------------------------------------------
# Plasma effect
# ---------------------------------------------------------------------------

_t = 0.0


def _tick_plasma():
    global _t
    _t += 0.06  # phase step per frame; lower = slower, calmer plasma drift
    n = len(PLASMA_PALETTE)

    buf = [[0] * W for _ in range(H)]
    for y in range(H):
        for x in range(W):
            v = (math.sin(x / 6.0 + _t)
                 + math.sin(y / 4.0 - 1.3 * _t)
                 + math.sin((x + y) / 8.0 + 0.7 * _t))
            # v ∈ [-3, 3] → normalize → [0, n-1]
            idx = int((v + 3.0) / 6.0 * (n - 1) + 0.5)
            buf[y][x] = max(0, min(n - 1, idx))

    return _flatten(buf, PLASMA_PALETTE)

# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------

_TICKS = {"fire": _tick_fire, "rain": _tick_rain, "plasma": _tick_plasma}
FRAME_T = 1.0 / 20.0  # cap; the image push itself paces us to ~18 fps


def main():
    if EFFECT == "rain":
        _init_rain()
    tick = _TICKS[EFFECT]
    print(f"pixel_fire [{EFFECT}] → {BASE}  (Ctrl-C to stop)")
    try:
        while True:
            t0 = time.monotonic()
            show(tick())
            dt = time.monotonic() - t0
            if dt < FRAME_T:
                time.sleep(FRAME_T - dt)
    except KeyboardInterrupt:
        print("\nstopped.")
    except urllib.error.HTTPError as e:
        sys.exit(f"error: HTTP {e.code} — {e.read().decode('utf-8', 'ignore')}")
    except urllib.error.URLError as e:
        sys.exit(f"error: cannot reach {BASE} — {e.reason}")
    finally:
        _clear()


if __name__ == "__main__":
    main()
manifest.yaml
name: Pixel Fire
author: maxswinkels
description: Demoscene pixel effects - fire, matrix rain, and plasma.
tags:
  - effect
  - animation
preview: ./preview.gif

Source and Provenance

View This Version on GitHub →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: f72cf87a14d8bc75472abce05ef68d14e384db7ced4d1b0ac89040cfb7706ebf

The Community Workbench

Rate and Discuss

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