Pixel Fire

Pixel Fire

by @maxswinkels

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

zero-install effectanimation

How to run

This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:

python app.py

Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP with --host <address>.

No bar at hand? Run the BUSY Bar Emulator and point the app at it: python app.py --host 127.0.0.1:8080.

Source code

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()
View source on GitHub →