BUSY.Tedt.org BUSY Bar Apps
← All Apps

DVD Bounce

The classic bouncing DVD screensaver. A colorful pixel logo ricochets around the bar, changes color at every bounce, and occasionally hits the perfect corner.

Updated Sep 14, 2026 · Added Sep 14, 2026 · ↓ 0 package download starts

By EzraMoussa · ▲ 0 · Rate and Discuss

How It Works

The classic bouncing DVD screensaver. A colorful pixel logo ricochets around the bar, changes color at every bounce, and occasionally hits the perfect corner.

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
"""DVD Bounce: a color-changing screensaver for the BUSY Bar's 72x16 LEDs.

    python3 app.py --host 127.0.0.1:8321
    python3 app.py --host 127.0.0.1:8080 --speed 8 --corner-flash

Standard library only. The manager discovers controls through --help.
"""

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

APP = "dvd-bounce"
W, H = 72, 16
BLACK = (0, 0, 0)
SPRITES = {
    "dvd": (
        "####..#...#.####.",
        "#...#.#...#.#...#",
        "#...#.#...#.#...#",
        "#...#..#.#..#...#",
        "####....#...####.",
        ".................",
        "..#####...#####..",
        "....#########....",
    ),
    "ball": (
        ".###.",
        "#####",
        "#####",
        "#####",
        ".###.",
    ),
}
PALETTES = {
    "classic": ((0, 170, 255), (255, 60, 170), (255, 220, 0),
                (60, 255, 90), (180, 80, 255), (255, 100, 30)),
    "neon": ((0, 255, 255), (255, 0, 180), (160, 255, 0), (160, 60, 255)),
    "pastel": ((130, 200, 255), (255, 150, 190), (255, 220, 140),
               (160, 240, 180), (200, 170, 255)),
}


class Bounce:
    """Reflect velocity at each wall, preserving speed and leftover travel time."""

    def __init__(self, speed=10, angle=45, logo="dvd", palette="classic",
                 brightness=80, seed=None, corner_flash=False):
        self.sprite = SPRITES[logo]
        self.max_x = W - len(self.sprite[0])
        self.max_y = H - len(self.sprite)
        self.rng = random.Random(seed)
        # Integer starts + the default 45-degree path allow real corner hits.
        self.x = float(self.rng.randrange(self.max_x + 1))
        self.y = float(self.rng.randrange(self.max_y + 1))
        radians = math.radians(angle)
        self.vx = speed * math.cos(radians) * self.rng.choice((-1, 1))
        self.vy = speed * math.sin(radians) * self.rng.choice((-1, 1))
        self.palette = PALETTES[palette]
        self.color_index = self.rng.randrange(len(self.palette))
        self.brightness = brightness / 100.0
        self.corner_flash = corner_flash
        self.flash_remaining = 0.0
        self.bounces = 0
        self.corners = 0

    def update(self, dt):
        if not math.isfinite(dt) or dt < 0:
            raise ValueError("elapsed time must be finite and nonnegative")
        remaining = dt
        while remaining > 0:
            tx = ((self.max_x if self.vx > 0 else 0) - self.x) / self.vx
            ty = ((self.max_y if self.vy > 0 else 0) - self.y) / self.vy
            collision = max(0.0, min(tx, ty))
            step = min(remaining, collision)
            self.x += self.vx * step
            self.y += self.vy * step
            self.flash_remaining = max(0.0, self.flash_remaining - step)
            if collision > remaining:
                break
            remaining -= step
            hit_x = math.isclose(tx, collision, rel_tol=0, abs_tol=1e-9)
            hit_y = math.isclose(ty, collision, rel_tol=0, abs_tol=1e-9)
            if hit_x:
                self.x = float(self.max_x if self.vx > 0 else 0)
                self.vx = -self.vx
            if hit_y:
                self.y = float(self.max_y if self.vy > 0 else 0)
                self.vy = -self.vy
            self.bounces += 1
            # Every impact gets a different color; a corner is one event.
            self.color_index = (self.color_index + self.rng.randrange(
                1, len(self.palette))) % len(self.palette)
            if hit_x and hit_y:
                self.corners += 1
                if self.corner_flash:
                    self.flash_remaining = 0.5

    def pixels(self):
        color = ((255, 255, 255) if self.flash_remaining > 0
                 else self.palette[self.color_index])
        color = tuple(round(c * self.brightness) for c in color)
        pixels = [BLACK] * (W * H)
        x = max(0, min(self.max_x, round(self.x)))
        y = max(0, min(self.max_y, round(self.y)))
        for sy, row in enumerate(self.sprite):
            for sx, pixel in enumerate(row):
                if pixel == "#":
                    pixels[(y + sy) * W + x + sx] = color
        return pixels


def png(pixels):
    """Encode one opaque RGBA frame without a Pillow dependency."""
    raw = bytearray()
    for y in range(H):
        raw.append(0)
        for r, g, b in pixels[y * W:(y + 1) * W]:
            raw.extend((r, g, b, 255))

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


class Display:
    def __init__(self, host):
        self.base = (host if "://" in host else "http://" + host).rstrip("/")
        self.frame = 0

    def request(self, path, data=None, content_type="application/json", method="POST"):
        req = urllib.request.Request(self.base + path, data=data, method=method,
                                     headers={"Content-Type": content_type})
        with urllib.request.urlopen(req, timeout=2) as response:
            return response.status

    def show(self, pixels):
        # Rotate assets: firmware briefly locks an image while rendering it.
        filename = "frame%d.png" % (self.frame % 4)
        self.frame += 1
        query = urllib.parse.urlencode({"application_name": APP, "file": filename})
        self.request("/api/assets/upload?" + query, png(pixels), "application/octet-stream")
        body = {"application_name": APP, "priority": 10,
                "elements": [{"id": "frame", "type": "image", "path": filename,
                              "x": 0, "y": 0}]}
        try:
            return self.request("/api/display/draw", json.dumps(body).encode())
        except urllib.error.HTTPError as exc:
            if exc.code == 409:  # A higher-priority app owns the screen.
                return 409
            raise

    def clear(self):
        try:
            self.request("/api/display/draw?application_name=" + APP, method="DELETE")
        except (urllib.error.URLError, OSError):
            pass


def parse_args(argv=None):
    parser = argparse.ArgumentParser(description="Bouncing DVD screensaver for BUSY Bar")
    parser.add_argument("--host", default="10.0.4.20", help="bar or manager host:port")
    parser.add_argument("--speed", type=float, default=10, metavar="2.0-40.0",
                        help="travel speed in pixels/second (default: 10)")
    parser.add_argument("--angle", type=float, default=45, metavar="5.0-85.0",
                        help="diagonal angle in degrees (default: 45)")
    parser.add_argument("--fps", type=int, default=17, metavar="1-20",
                        help="frames per second (default: 17)")
    parser.add_argument("--logo", choices=["dvd", "ball"], default="dvd",
                        help="bouncing sprite (default: dvd)")
    parser.add_argument("--palette", choices=["classic", "neon", "pastel"], default="classic",
                        help="colors used at each bounce (default: classic)")
    parser.add_argument("--brightness", type=int, default=80, metavar="1-100",
                        help="logo brightness percentage (default: 80)")
    parser.add_argument("--corner-flash", action="store_true",
                        help="flash the logo white for a perfect corner hit")
    parser.add_argument("--seed", type=int, default=None,
                        help="optional seed to repeat a path and its colors")
    parser.add_argument("--test", action="store_true", help="draw one frame and exit")
    args = parser.parse_args(argv)
    for name, low, high in (("speed", 2, 40), ("angle", 5, 85),
                           ("fps", 1, 20), ("brightness", 1, 100)):
        value = getattr(args, name)
        if not math.isfinite(value) or not low <= value <= high:
            parser.error("--%s must be between %s and %s" % (name, low, high))
    return args


def main(argv=None):
    args = parse_args(argv)
    bounce = Bounce(args.speed, args.angle, args.logo, args.palette,
                    args.brightness, args.seed, args.corner_flash)
    display = Display(args.host)
    if args.test:
        # Leave the single test frame visible, like the other animation apps.
        print("test: drew one DVD Bounce frame (status %s)" % display.show(bounce.pixels()))
        return

    stopped = threading.Event()
    for sig in (signal.SIGINT, signal.SIGTERM):
        signal.signal(sig, lambda *_: stopped.set())
    print("DVD Bounce -> %s | %s, %g px/s, %g degrees" %
          (display.base, args.logo, args.speed, args.angle), flush=True)
    previous = time.monotonic()
    last_warning = -math.inf
    try:
        while not stopped.is_set():
            started = time.monotonic()
            corners = bounce.corners
            bounce.update(started - previous)
            previous = started
            if bounce.corners > corners:
                print("Corner hit! Total: %d" % bounce.corners, flush=True)
            try:
                display.show(bounce.pixels())
            except (urllib.error.URLError, OSError) as exc:
                if started - last_warning >= 10:
                    print("Display unavailable; retrying: %s" % exc, file=sys.stderr, flush=True)
                    last_warning = started
                stopped.wait(1)
            stopped.wait(max(0, 1.0 / args.fps - (time.monotonic() - started)))
    finally:
        display.clear()
        print("DVD Bounce stopped.", flush=True)


if __name__ == "__main__":
    main()
manifest.yaml
name: DVD Bounce
author: EzraMoussa
description: The classic bouncing DVD screensaver. A colorful pixel logo ricochets around the bar, changes color at every bounce, and occasionally hits the perfect corner.
tags:
  - animation
  - screensaver
  - retro
preview: ./preview.gif

Source and Provenance

View This Version on GitHub →

Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: 1ac85a63456cde58314c47aa253ee4e02e442ef667c6eb073dc39d9b495587ae

The Community Workbench

Rate and Discuss

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