BusyBar Pinball

BusyBar Pinball

by @robynhub

A colorful 90s-style pinball DMD attract mode with scores, jackpots, bonus multipliers, racing animations, Extra Ball, and Insert Coin sequences.

zero-install pinballretroanimationambient

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.

To keep it running without a terminal open, install it with busybar-manager, which handles the virtualenv, autostart and scheduling, and lets you switch between apps from a web dashboard.

Source code

app.py
#!/usr/bin/env python3
"""Pinball DMD demo for BUSY Bar.

A procedural 90s/early-2000s pinball dot-matrix-display inspired attract mode
for the 72x16 BUSY Bar. No copyrighted table graphics are used.

Examples:
  python3 pinball_busybar_v5.py
  python3 pinball_busybar_v5.py --host 127.0.0.1:8080
  python3 pinball_busybar_v5.py --fps 14 --palette color
"""
import argparse
import json
import math
import random
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib

APP = "busybar-pinball"
W, H = 72, 16

PALETTES = {
    "amber": [(8, 2, 0), (70, 15, 0), (160, 45, 0), (255, 120, 10)],
    "red":   [(8, 0, 0), (70, 0, 0), (170, 10, 0), (255, 60, 10)],
}

# Multicolour mode keeps the black-background DMD character, but uses a
# restrained arcade palette to separate information and give each scene its
# own identity. Level 1/2/3 still controls brightness within the chosen hue.
COLOR_RAMPS = {
    "orange": [(20, 4, 0), (95, 20, 0), (190, 55, 0), (255, 135, 20)],
    "yellow": [(18, 12, 0), (85, 55, 0), (190, 130, 0), (255, 225, 70)],
    "red":    [(20, 0, 0), (95, 0, 5), (195, 15, 20), (255, 65, 35)],
    "green":  [(0, 14, 4), (0, 75, 20), (20, 170, 55), (90, 255, 120)],
    "cyan":   [(0, 10, 18), (0, 65, 90), (0, 155, 195), (75, 235, 255)],
    "blue":   [(2, 4, 22), (10, 35, 105), (20, 85, 210), (80, 160, 255)],
    "purple": [(12, 0, 20), (60, 10, 90), (135, 25, 190), (225, 85, 255)],
    "pink":   [(20, 0, 10), (95, 10, 55), (200, 25, 120), (255, 95, 185)],
    "white":  [(12, 12, 12), (75, 75, 75), (170, 170, 170), (255, 255, 255)],
}

FONT = {
    "A": ["01110","10001","10001","11111","10001","10001","10001"],
    "B": ["11110","10001","10001","11110","10001","10001","11110"],
    "C": ["01111","10000","10000","10000","10000","10000","01111"],
    "D": ["11110","10001","10001","10001","10001","10001","11110"],
    "E": ["11111","10000","10000","11110","10000","10000","11111"],
    "F": ["11111","10000","10000","11110","10000","10000","10000"],
    "G": ["01111","10000","10000","10111","10001","10001","01111"],
    "H": ["10001","10001","10001","11111","10001","10001","10001"],
    "I": ["11111","00100","00100","00100","00100","00100","11111"],
    "J": ["00111","00010","00010","00010","10010","10010","01100"],
    "K": ["10001","10010","10100","11000","10100","10010","10001"],
    "L": ["10000","10000","10000","10000","10000","10000","11111"],
    "M": ["10001","11011","10101","10101","10001","10001","10001"],
    "N": ["10001","11001","10101","10011","10001","10001","10001"],
    "O": ["01110","10001","10001","10001","10001","10001","01110"],
    "P": ["11110","10001","10001","11110","10000","10000","10000"],
    "Q": ["01110","10001","10001","10001","10101","10010","01101"],
    "R": ["11110","10001","10001","11110","10100","10010","10001"],
    "S": ["01111","10000","10000","01110","00001","00001","11110"],
    "T": ["11111","00100","00100","00100","00100","00100","00100"],
    "U": ["10001","10001","10001","10001","10001","10001","01110"],
    "V": ["10001","10001","10001","10001","10001","01010","00100"],
    "W": ["10001","10001","10001","10101","10101","11011","10001"],
    "X": ["10001","10001","01010","00100","01010","10001","10001"],
    "Y": ["10001","10001","01010","00100","00100","00100","00100"],
    "Z": ["11111","00001","00010","00100","01000","10000","11111"],
    "0": ["01110","10001","10011","10101","11001","10001","01110"],
    "1": ["00100","01100","00100","00100","00100","00100","01110"],
    "2": ["01110","10001","00001","00010","00100","01000","11111"],
    "3": ["11110","00001","00001","01110","00001","00001","11110"],
    "4": ["00010","00110","01010","10010","11111","00010","00010"],
    "5": ["11111","10000","10000","11110","00001","00001","11110"],
    "6": ["01110","10000","10000","11110","10001","10001","01110"],
    "7": ["11111","00001","00010","00100","01000","01000","01000"],
    "8": ["01110","10001","10001","01110","10001","10001","01110"],
    "9": ["01110","10001","10001","01111","00001","00001","01110"],
    "!": ["00100","00100","00100","00100","00100","00000","00100"],
    "-": ["00000","00000","00000","11111","00000","00000","00000"],
    " ": ["000","000","000","000","000","000","000"],
}


def parse_args():
    p = argparse.ArgumentParser(description="90s pinball DMD inspired attract mode for BUSY Bar")
    p.add_argument("--host", default="10.0.4.20")
    p.add_argument("--title-top", default="BUSYBAR",
                   help="top word on the opening title screen (default: BUSYBAR)")
    p.add_argument("--title-bottom", default="PINBALL",
                   help="bottom word on the opening title screen (default: PINBALL)")
    p.add_argument("--fps", type=int, default=15)
    p.add_argument("--palette", choices=["color", *sorted(PALETTES)], default="color",
                   help="display palette (default: color; amber/red emulate classic monochrome DMDs)")
    p.add_argument("--test", action="store_true", help="render a few frames locally as PNGs and exit")
    return p.parse_args()


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


def _png(pixels):
    raw = bytearray()
    for y in range(H):
        raw.append(0)
        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""))


_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 r:
        return r.getcode()


def show(host, pixels):
    global _frame_no
    fn = "frame%d.png" % (_frame_no % _RING)
    _frame_no += 1
    try:
        _post(host, f"/api/assets/upload?application_name={APP}&file={fn}", _png(pixels), "application/octet-stream")
        body = {"application_name": APP, "elements": [{"id": "frame", "type": "image", "path": fn, "x": 0, "y": 0}]}
        return _post(host, "/api/display/draw", json.dumps(body).encode(), "application/json")
    except urllib.error.HTTPError as e:
        if e.code != 409:
            raise
        return e.code


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 Exception:
        pass


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


def px(buf, x, y, v):
    if 0 <= x < W and 0 <= y < H:
        buf[y * W + x] = max(buf[y * W + x], max(0, min(3, int(v))))


def rect(buf, x, y, w, h, v):
    for yy in range(y, y + h):
        for xx in range(x, x + w):
            px(buf, xx, yy, v)


def line(buf, x0, y0, x1, y1, v):
    dx, sx = abs(x1 - x0), 1 if x0 < x1 else -1
    dy, sy = -abs(y1 - y0), 1 if y0 < y1 else -1
    err = dx + dy
    while True:
        px(buf, x0, y0, v)
        if x0 == x1 and y0 == y1:
            break
        e2 = 2 * err
        if e2 >= dy:
            err += dy; x0 += sx
        if e2 <= dx:
            err += dx; y0 += sy


def text_width(s, scale=1):
    return sum((len(FONT.get(ch, FONT[" "])[0]) + 1) * scale for ch in s.upper()) - scale


def draw_text(buf, s, x, y, v=3, scale=1):
    pen = x
    for ch in s.upper():
        glyph = FONT.get(ch, FONT[" "])
        gw = len(glyph[0])
        for gy, row in enumerate(glyph):
            for gx, bit in enumerate(row):
                if bit == "1":
                    rect(buf, pen + gx * scale, y + gy * scale, scale, scale, v)
        pen += (gw + 1) * scale


def centered_text(buf, s, y, v=3, scale=1):
    draw_text(buf, s, (W - text_width(s, scale)) // 2, y, v, scale)


def tracked_text_width(s, spacing=2):
    """Pixel width with explicit inter-letter tracking."""
    chars = s.upper()
    if not chars:
        return 0
    return sum(len(FONT.get(ch, FONT[" "])[0]) for ch in chars) + spacing * (len(chars) - 1)


def draw_tracked_text(buf, s, x, y, v=3, spacing=2):
    """Draw 5x7 text with a configurable number of pixels between glyphs."""
    pen = x
    for ch in s.upper():
        glyph = FONT.get(ch, FONT[" "])
        gw = len(glyph[0])
        for gy, row in enumerate(glyph):
            for gx, bit in enumerate(row):
                if bit == "1":
                    px(buf, pen + gx, y + gy, v)
        pen += gw + spacing


def centered_bold_text(buf, s, y, v=3, spacing=1):
    """Draw a 1px-thickened 5x7 title with optional inter-letter tracking."""
    width = tracked_text_width(s, spacing)
    x = (W - (width + 1)) // 2
    draw_tracked_text(buf, s, x, y, v, spacing)
    draw_tracked_text(buf, s, x + 1, y, v, spacing)


def dither_rgb(level_buf, palette):
    # Keep the image discrete, not anti-aliased.
    return [palette[v] for v in level_buf]


def colorize(level_buf, scene_name, t):
    """Map intensity pixels to a restrained per-scene arcade colour scheme.

    The renderer itself remains a 4-level DMD-style bitmap. Colour is applied
    only here, so the geometry, dithering and hard pixel edges stay intact.
    """
    out = []
    pulse = int(t * 6) & 1
    for i, level in enumerate(level_buf):
        if level <= 0:
            out.append((0, 0, 0))
            continue
        x, y = i % W, i // W

        if scene_name == "logo":
            # Keep each title line monochrome regardless of its text length.
            # The starburst outside the lettering retains the animated accent.
            if 1 <= y <= 7:
                hue = "purple"
            elif 9 <= y <= 15:
                hue = "cyan"
            else:
                hue = "blue" if (x // 5 + pulse) % 2 else "pink"

        elif scene_name == "score":
            # SCORE, score value, BALL and multiplier are each monochrome blocks.
            score_w = text_width("SCORE")
            if y < 8 and 1 <= x < 1 + score_w:
                hue = "cyan"
            elif y < 8:
                hue = "yellow"
            elif y >= 9 and x < 43:
                hue = "green"
            elif y >= 9 and x >= 55:
                hue = "pink"
            else:
                hue = "orange"

        elif scene_name == "race":
            # Road/track is blue-white; the racing car gets hot bodywork and cool glass.
            if y >= 13:
                hue = "white"
            elif y <= 7:
                hue = "red"
            else:
                hue = "cyan" if (x // 3) % 2 else "orange"

        elif scene_name == "multiball":
            # MULTIBALL itself stays monochrome; surrounding balls/bumper sparks
            # provide the colour without splitting the lettering.
            title_w = tracked_text_width("MULTIBALL", 2) + 1
            title_x = (W - title_w) // 2
            title_y = min(5, max(-7, int(t * 12) - 7))
            if title_y <= y <= title_y + 7 and title_x <= x < title_x + title_w:
                hue = "yellow"
            elif (x < 14 or x > 57):
                hue = "purple" if (x + y + pulse) % 2 else "cyan"
            else:
                hue = "red" if (x + y) % 3 == 0 else "orange"

        elif scene_name == "jackpot":
            # Both text lines are strictly monochrome; only surrounding sparks vary.
            title_x = (W - text_width("JACKPOT!")) // 2
            amount_x = (W - text_width("5000000")) // 2
            if 0 <= y <= 6 and title_x <= x < title_x + text_width("JACKPOT!"):
                hue = "red"
            elif 9 <= y <= 15 and amount_x <= x < amount_x + text_width("5000000"):
                hue = "yellow"
            else:
                hue = "purple" if (x + y + pulse) % 3 == 0 else "cyan"

        elif scene_name == "bonus":
            # BONUS and the multiplier are each a single hue.
            bonus_x = (W - text_width("BONUS")) // 2
            if 0 <= y <= 6 and bonus_x <= x < bonus_x + text_width("BONUS"):
                hue = "green"
            elif 7 <= y <= 15 and 24 <= x <= 48:
                hue = "yellow"
            else:
                hue = "cyan" if (x // 4 + pulse) % 2 else "purple"

        elif scene_name == "extra_ball":
            # The scrolling phrase itself is always one colour. Decorative
            # pixels may use a second hue without splitting the lettering.
            if 4 <= y <= 10:
                hue = "pink"
            else:
                hue = "purple" if (x // 5 + pulse) % 2 else "cyan"

        elif scene_name == "insert_coin":
            hue = "yellow"
        elif scene_name == "super_jackpot":
            # Both words stay monochrome; sparks provide the extra colour.
            if 0 <= y <= 6:
                hue = "purple"
            elif 9 <= y <= 15:
                hue = "yellow"
            else:
                hue = "red" if (x + y + pulse) % 2 else "cyan"
        elif scene_name == "ball_locked":
            # BALL LOCKED remains a single-colour message; lock/ball accents vary.
            if 0 <= y <= 6:
                hue = "cyan"
            elif 9 <= y <= 15:
                hue = "yellow"
            else:
                hue = "purple" if (x + pulse) % 2 else "white"
        elif scene_name == "tilt":
            hue = "red"
        elif scene_name == "match":
            # MATCH and the selected number are separate monochrome text blocks.
            # Decorative edge lamps provide the secondary colours.
            if y <= 6:
                hue = "cyan"
            elif 8 <= y <= 15 and 24 <= x <= 48:
                hue = "yellow"
            else:
                hue = "purple" if (x + pulse) % 2 else "pink"
        else:
            hue = "orange"

        out.append(COLOR_RAMPS[hue][level])
    return out


TITLE_TOP = "BUSYBAR"
TITLE_BOTTOM = "PINBALL"

def scene_logo(t, cycle=0):
    b = blank()
    # expanding concentric diamond / starburst
    cx, cy = 36, 7
    r = 3 + int((t * 14) % 20)
    for k, v in ((r, 1), (r-4, 2), (r-8, 3)):
        if k > 0:
            line(b, cx-k, cy, cx, cy-k//2, v); line(b, cx, cy-k//2, cx+k, cy, v)
            line(b, cx+k, cy, cx, cy+k//2, v); line(b, cx, cy+k//2, cx-k, cy, v)
    centered_text(b, TITLE_TOP, 1, 2)
    centered_text(b, TITLE_BOTTOM, 9, 3)
    return b


_score_cycle = None
_score_value = 0
_score_step = -1


def scene_score(t, cycle=0):
    b = blank()

    # Stateful score simulation. Every attract loop starts from a different
    # plausible score, then adds random pinball-like awards several times per
    # second while this card is visible. The score therefore only goes up.
    global _score_cycle, _score_value, _score_step
    step = int(t * 6.0)
    if cycle != _score_cycle:
        _score_cycle = cycle
        _score_value = random.randrange(10000, 90000, 100)
        _score_step = -1
    while _score_step < step:
        _score_step += 1
        if _score_step == 0:
            continue
        award = random.choice((100, 250, 500, 1000, 2500, 5000, 10000))
        # Occasional larger award, as if a target bank / mode just completed.
        if random.random() < 0.12:
            award *= random.choice((2, 3))
        _score_value = min(999999, _score_value + award)

    ball = 1 + ((cycle * 5 + 1) % 3)
    mult = 2 + ((cycle + int(t)) % 4)
    draw_text(b, "SCORE", 1, 0, 1)
    s = f"{_score_value:06d}"
    draw_text(b, s, max(0, 71 - text_width(s)), 0, 3)
    draw_text(b, f"BALL {ball}", 1, 9, 2)
    draw_text(b, f"X{mult}", 55, 9, 3)
    if int(t * 5) % 2:
        rect(b, 43, 10, 2, 2, 2); rect(b, 47, 10, 2, 2, 1)
    return b

def scene_jackpot(t, cycle=0):
    b = blank()
    phase = int(t * 10) % 8
    for x in range(-10, 82, 12):
        line(b, x + phase, 15, x + 10 + phase, 0, 1)
    centered_text(b, "JACKPOT!", 0, 3)
    amount = "5000000"
    centered_text(b, amount, 9, 2)
    # sparks
    for i in range(10):
        a = i * math.pi * 0.2 + t * 4
        rr = 6 + (i % 3)
        px(b, 36 + int(math.cos(a) * rr), 8 + int(math.sin(a) * rr * 0.45), 3)
    return b


def scene_race(t, cycle=0):
    b = blank()
    # Side-view racing car crossing the display from left to right.
    car_x = int((t * 25) % (W + 20)) - 18
    bob = 1 if int(t * 10) % 2 else 0
    y = 6 + bob

    # Dashed road and a moving lane marker sell the racing motion.
    rect(b, 0, 14, W, 1, 1)
    road_phase = int(t * 30) % 12
    for x in range(-road_phase, W, 12):
        rect(b, x, 12, 6, 1, 1)

    # Compact Formula/GT-like silhouette: nose -> cockpit -> rear wing.
    rect(b, car_x + 2, y + 3, 13, 3, 3)        # body
    rect(b, car_x + 12, y + 2, 5, 2, 3)        # nose
    rect(b, car_x + 5, y + 1, 6, 2, 2)         # cockpit
    rect(b, car_x + 1, y, 2, 4, 2)             # rear wing upright
    rect(b, car_x, y, 5, 1, 3)                 # rear wing
    rect(b, car_x + 7, y, 3, 1, 1)             # windscreen/glass
    # Wheels, with bright hubs on alternating frames.
    for wx in (car_x + 4, car_x + 13):
        rect(b, wx, y + 5, 3, 2, 2)
        px(b, wx + 1, y + 5, 3 if int(t * 12) % 2 else 1)
    # A few exhaust/speed pixels behind the car.
    for i in range(4):
        px(b, car_x - 2 - i * 3, y + 4 + (i & 1), max(1, 3 - i // 2))
    return b

def scene_multiball(t, cycle=0):
    """Bold MULTIBALL drops from above, surrounded by pinball-style pixel art."""
    b = blank()

    # Enter from above and settle around the vertical centre.
    title_y = min(5, -7 + int(t * 12))
    centered_bold_text(b, "MULTIBALL", title_y, 3, spacing=2)

    # Two animated pinballs bounce near the lower corners. Their tiny highlights
    # make them read as metallic balls even at this resolution.
    bounce = abs((int(t * 12) % 10) - 5)
    for bx, phase in ((8, 0), (63, 3)):
        by = 12 - max(0, 3 - abs(bounce - phase))
        rect(b, bx - 1, by - 1, 3, 3, 2)
        px(b, bx - 1, by - 1, 3)
        px(b, bx, by, 1)

    # Mini bumper/starbursts at the sides. Alternate intensity for a mechanical
    # pop effect rather than a smooth animation.
    flash = 3 if int(t * 8) % 2 else 1
    for cx, cy in ((18, 12), (53, 12)):
        px(b, cx, cy, 3)
        px(b, cx - 2, cy, flash); px(b, cx + 2, cy, flash)
        px(b, cx, cy - 2, flash); px(b, cx, cy + 2, flash)
        px(b, cx - 1, cy - 1, 2); px(b, cx + 1, cy - 1, 2)

    # A few travelling spark pixels keep the card alive after the title settles.
    spark_phase = int(t * 20)
    for i in range(5):
        sx = (spark_phase + i * 15) % W
        sy = 1 + ((i * 3 + spark_phase // 3) % 14)
        px(b, sx, sy, 1 + (i % 3))
    return b


def scene_bonus(t, cycle=0):
    b = blank()
    centered_text(b, "BONUS", 0, 2)
    multiplier = 1 + int(t * 2.0) % 10
    centered_text(b, f"{multiplier}X", 7, 3, scale=1)
    # moving scanlines and side chevrons
    y = int((t * 8) % H)
    for x in range(0, W, 2):
        px(b, x, y, 1)
    for yy in range(2, 14, 3):
        px(b, 2, yy, 2); px(b, 3, yy+1, 2); px(b, 69, yy, 2); px(b, 68, yy+1, 2)
    return b


def scene_extra_ball(t, cycle=0):
    b = blank()
    phrase = "EXTRA BALL"
    width = text_width(phrase)

    # Scroll once, fully from right to left. The scene duration is long enough
    # for the final letter to leave the display before the next attract card.
    x = W - int(t * 31)
    draw_text(b, phrase, x, 4, 3)

    # Bonus-style moving border / sparkle treatment.
    phase = int(t * 14) % 8
    for xx in range(-phase, W, 8):
        px(b, xx, 1, 1)
        px(b, W - 1 - xx, 14, 1)
    for i in range(5):
        sx = (int(t * 23) + i * 17) % W
        sy = 2 + ((i * 5 + int(t * 9)) % 11)
        px(b, sx, sy, 2 if i & 1 else 1)
    return b


def scene_insert_coin(t, cycle=0):
    """Large INSERT/COIN rolls upward, then the complete message flashes twice."""
    b = blank()

    # Phase 1: two oversized words form one vertical rolling message. At scale
    # 2, INSERT is 70px wide and still fits the 72px BUSY Bar almost edge-to-edge.
    # COIN follows immediately behind it from below.
    roll_time = 2.35
    if t < roll_time:
        speed = 15.5  # pixels/sec upward
        insert_y = 16 - int(t * speed)
        coin_y = insert_y + 18
        centered_text(b, "INSERT", insert_y, 3, scale=2)
        centered_text(b, "COIN", coin_y, 3, scale=2)

        # Sparse vertical motion streaks make the upward movement read clearly
        # without competing with the lettering.
        phase = int(t * 18) % 9
        for x in (3, 68):
            for yy in range(phase - 9, H, 9):
                rect(b, x, yy, 1, 3, 1)
        return b

    # Phase 2: settle the complete phrase on two lines, flash it exactly twice
    # (ON-OFF-ON-OFF), then hold it steadily ON for one full second.
    ft = t - roll_time
    flash_step = int(ft / 0.24)
    visible = flash_step in (0, 2) or ft >= 0.96
    if visible:
        centered_text(b, "INSERT", 0, 3)
        centered_text(b, "COIN", 9, 3)
    return b


def scene_super_jackpot(t, cycle=0):
    b = blank()
    # A short pixel zoom: SUPER settles first, then JACKPOT slams in underneath.
    super_y = max(0, 5 - int(t * 12))
    jackpot_y = min(9, 16 - int(max(0.0, t - 0.28) * 18))
    centered_text(b, "SUPER", super_y, 2)
    centered_text(b, "JACKPOT", jackpot_y, 3)
    # Expanding starburst around the message.
    rr = 2 + int((t * 15) % 12)
    for dx, dy in ((1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,1),(1,-1),(-1,-1)):
        px(b, 36 + dx * rr, 8 + dy * max(1, rr // 2), 1 + (rr % 3))
    return b


def scene_ball_locked(t, cycle=0):
    b = blank()
    centered_text(b, "BALL", 0, 2)
    centered_text(b, "LOCKED", 9, 3)
    # A pinball rolls into a small lock/cage at the right.
    ball_x = min(57, -4 + int(t * 28))
    ball_y = 7 + int(math.sin(t * 9) * 2)
    rect(b, ball_x, ball_y, 3, 3, 2); px(b, ball_x, ball_y, 3)
    # Lock body and shackle.
    rect(b, 61, 5, 8, 8, 1)
    rect(b, 63, 3, 4, 3, 2)
    rect(b, 64, 4, 2, 2, 0)
    if t > 2.0 and int(t * 8) % 2:
        rect(b, 60, 4, 10, 10, 3)
        rect(b, 61, 5, 8, 8, 0)
    return b


def scene_tilt(t, cycle=0):
    b = blank()
    # Deliberately stark: large red TILT! shakes, then briefly blacks out.
    if 1.55 < t < 1.85:
        return b
    shake = (-1, 1, 0, 1, -1, 0)[int(t * 18) % 6]
    centered_bold_text(b, "TILT!", 4 + shake, 3, spacing=2)
    # Warning bars at the edges vibrate in the opposite direction.
    for yy in range(1, 15, 3):
        px(b, 2 + shake, yy, 2); px(b, 69 - shake, yy, 2)
    return b


def scene_match(t, cycle=0):
    """Classic end-of-ball MATCH: spin the tens, settle, then flash the result."""
    b = blank()
    # Stable pseudo-random result for the whole attract cycle: 00, 10 ... 90.
    rng = random.Random(0x4D41544348 + cycle * 7919)
    result = rng.randrange(10) * 10

    centered_text(b, "MATCH", 0, 3)

    # For the first part, race through the possible match numbers like a reel.
    # The reel progressively slows before snapping to the selected value.
    settle_at = 2.45
    if t < settle_at:
        # Fast at first, slower near the stop. Quantising the phase gives the
        # characteristic stepped electromechanical / DMD number roll.
        progress = t / settle_at
        rate = 13.0 - 8.0 * progress
        step = int(t * rate + t * t * 1.7)
        value = ((step + cycle * 3) % 10) * 10
        centered_bold_text(b, f"{value:02d}", 8, 3, spacing=2)
    else:
        # Once stopped, flash the winning number twice, then leave it lit.
        ft = t - settle_at
        visible = True
        if ft < 1.0:
            visible = int(ft / 0.25) % 2 == 0
        if visible:
            centered_bold_text(b, f"{result:02d}", 8, 3, spacing=2)

    # Small side lamps chase while the reel spins and freeze when it settles.
    lamp_phase = int(t * 10) if t < settle_at else result // 10
    for i, yy in enumerate((2, 6, 10, 14)):
        v = 3 if (i + lamp_phase) % 4 == 0 else 1
        rect(b, 2, yy, 2, 1, v)
        rect(b, 68, yy, 2, 1, v)
    return b


# The attract sequence deliberately ends with INSERT COIN again. The same scene
# function is reused, so the closing card repeats the full rise/flash/hold motion.
SCENES = [scene_logo, scene_score, scene_race, scene_multiball, scene_jackpot, scene_bonus,
          scene_extra_ball, scene_insert_coin, scene_super_jackpot, scene_ball_locked,
          scene_tilt, scene_match, scene_insert_coin]
DURATIONS = [2.6, 3.8, 3.0, 3.7, 2.4, 2.8, 4.55, 4.31, 2.9, 3.2, 2.2, 4.1, 4.31]

SCENE_NAMES = ["logo", "score", "race", "multiball", "jackpot", "bonus", "extra_ball",
               "insert_coin", "super_jackpot", "ball_locked", "tilt", "match", "insert_coin"]


def render_at(t):
    total = sum(DURATIONS)
    cycle = int(t // total)
    tt = t % total
    acc = 0.0
    for scene, scene_name, dur in zip(SCENES, SCENE_NAMES, DURATIONS):
        if tt < acc + dur:
            local = tt - acc
            return scene(local, cycle), scene_name, local
        acc += dur
    return SCENES[0](0, cycle), SCENE_NAMES[0], 0.0


def _rgb_frame(levels, scene_name, local_t, palette_name):
    if palette_name == "color":
        return colorize(levels, scene_name, local_t)
    return dither_rgb(levels, PALETTES[palette_name])


def save_test_frames(palette_name):
    for i, ts in enumerate((0.4, 3.2, 7.0, 10.4, 13.0, 16.0)):
        levels, scene_name, local_t = render_at(ts)
        data = _png(_rgb_frame(levels, scene_name, local_t, palette_name))
        path = f"/mnt/data/pinball_dmd_preview_{i+1}.png"
        with open(path, "wb") as f:
            f.write(data)
        print(path)


def main():
    global TITLE_TOP, TITLE_BOTTOM
    args = parse_args()
    TITLE_TOP = args.title_top.upper()
    TITLE_BOTTOM = args.title_bottom.upper()
    if args.test:
        save_test_frames(args.palette)
        return
    interval = 1.0 / max(1, args.fps)
    print(f"busybar-pinball -> {_base(args.host)}  palette={args.palette}  (Ctrl-C to stop)")
    t0 = time.monotonic()
    try:
        while True:
            ft = time.monotonic()
            levels, scene_name, local_t = render_at(ft - t0)
            show(args.host, _rgb_frame(levels, scene_name, local_t, args.palette))
            dt = time.monotonic() - ft
            if dt < interval:
                time.sleep(interval - dt)
    except KeyboardInterrupt:
        pass
    finally:
        clear(args.host)
        print("stopped.")


if __name__ == "__main__":
    main()
View source on GitHub →