I have a set of GoDice. They are Bluetooth Low Energy dice with built-in accelerometers. You roll them on a real table and each die knows which face is up, reporting the result over BLE to whatever is listening. They have been sitting on my desk for a while now, mostly used through the companion phone app, but that was never what I actually wanted to do with them.

From time to time I play tabletop RPG online with friends. We could do dice rolls in a VTT, which works fine and even has GoDice integration, but I wanted something different. Roll physical dice at my desk, have the result appear on stream, animated, in real time. No typing numbers into a chat box, no virtual dice roller. Actual dice, actual rolls, with the results composited into my camera feed.

My setup runs through OBS as a compositing layer. The base is a real camera feed from an OBSBOT Tiny 4K. On top of that I layer browser sources for overlays, one of which would be the dice results. The whole stack outputs as a virtual camera, which is what Discord or Google Meet or whatever the group uses that week sees as my webcam. When I roll dice at my desk, the result pops up right there in the video feed. No extra windows, no screen sharing.

I had been thinking about this for over a year but there was always something else to work on first. A few weekends ago I finally sat down and started building.

Picking the tools

The GoDice Python SDK wraps the BLE protocol and fires callbacks when a die lands on a face. It handles the connection lifecycle and face detection. That is all I need from it for this project.

For the UI I went with NiceGUI. A colleague had shown it to me a few weeks earlier and I was looking for an excuse to try it. It is a Python-only web UI framework where pages are written entirely in Python, no JavaScript required. The reason it fit well: I needed two separate web pages served from a single process. One is an admin panel for managing dice connections and settings. The other is a transparent overlay page that OBS loads as a browser source. NiceGUI handles both as routes in the same app.

Under the hood NiceGUI runs on FastAPI and communicates with the browser over WebSocket. That matters here because the overlay needs to react to dice events in real time. When a die lands, the backend pushes the result to the browser and the overlay animates it in. No polling, no refresh.

The rest of the stack: bleak for BLE scanning and device discovery, aiosqlite for persisting roll history to a local SQLite database, PyYAML for config persistence. Everything async, everything in one Python process.

Fighting BlueZ

Bluetooth Low Energy on Linux is an exercise in patience, at least for me and my recent projects. BlueZ, the Linux Bluetooth stack, sits behind D-Bus and has opinions about how you should talk to it. Connections drop for no apparent reason. Reconnects fail silently. If you scan for new devices while maintaining active connections, things get weird fast. I spent more time debugging bluetooth connections in recent weeks than writing the actual code.

My first real mistake was adding a “Connect All” button. Six dice, one click, easy. Except BlueZ does not handle concurrent connection attempts gracefully. Fire off six connect() calls in parallel and you get a mix of D-Bus errors, half-connected states, and at least one die that just vanishes from the adapter entirely. I could reliably crash the Bluetooth stack on my machine with this button.

The fix was blunt: serialize everything behind a single lock.

async def connect_die(self, config: DieConfig, retries: int = 3) -> None:
    if config.address in self._connecting:
        raise ConnectionError("Connection already in progress")
    self._connecting.add(config.address)
    try:
        async with self._connect_lock:
            return await self._do_connect(config, retries)
    finally:
        self._connecting.discard(config.address)

Two layers of protection here. The _connecting set is a guard against duplicate attempts for the same die, which can happen if a user clicks “Connect All” and then clicks an individual die’s connect button. The _connect_lock is an asyncio.Lock that ensures only one connection attempt runs at a time across all dice. “Connect All” still works, but the connections happen sequentially. With six dice that means waiting for each one to finish before the next starts, and you can feel the delay. It also actually works.

Each connection attempt gets up to three tries with a two-second delay between them. BlueZ sometimes just says no on the first attempt and then works fine on the second. If all three fail, the last exception propagates up to the caller so the UI can show what went wrong.

Disconnects are the other half of the problem. Dice go out of range, batteries die, BlueZ decides to renegotiate a connection and fails. When a die disconnects unexpectedly, the disconnect callback schedules a reconnect loop:

async def _reconnect_loop(self, config: DieConfig) -> None:
    max_attempts = 3
    delay = 5.0
    for attempt in range(1, max_attempts + 1):
        if config.address not in self._auto_reconnect:
            logger.info("Reconnect cancelled for %s (explicit disconnect)", config.address)
            return
        logger.info(
            "Auto-reconnect %s attempt %d/%d (waiting %.0fs)...",
            config.label or config.address, attempt, max_attempts, delay,
        )
        await asyncio.sleep(delay)
        try:
            await self.connect_die(config, retries=1)
            return
        except Exception as e:
            logger.warning("Auto-reconnect attempt %d/%d failed for %s: %s",
                attempt, max_attempts, config.address, e)
            delay *= 1.5
    logger.error("Auto-reconnect exhausted for %s", config.address)

Three attempts, exponential backoff starting at five seconds (5s, 7.5s, 11.25s). The important detail is the _auto_reconnect check at the top of each loop iteration. When a die connects successfully, its address gets added to _auto_reconnect. When the user explicitly disconnects a die, that entry is removed. So if a die is explicitly disconnected while a reconnect loop is running, the loop notices and stops. No phantom reconnections fighting the user.

A background task handles battery monitoring: poll every 30 seconds, cache the results, and set the die’s LED to red when it drops below 20%. Nothing clever there, just a periodic check that keeps the overlay’s battery indicators current without hammering the BLE connection.

The event pipeline

Every roll flows through the same path: BLE callback to event to subscribers. The _on_roll() handler in the connection manager creates a RollEvent and publishes it to an EventBus, which is just a list of async callbacks.

Three things subscribe to that bus. A database handler persists the roll. The overlay handler pushes it to the browser for animation. An admin log handler updates the live table on the dashboard. None of them know about each other, and none of them know about Bluetooth.

The bus itself handles cleanup. NiceGUI clients die all the time: browser tabs close, OBS reloads a source, someone refreshes the page. When that happens, the handler throws a RuntimeError, and the bus removes it automatically:

async def publish(self, event: RollEvent) -> None:
    dead: list[RollHandler] = []
    for handler in self._subscribers:
        try:
            await handler(event)
        except RuntimeError:
            dead.append(handler)
        except Exception:
            logger.exception("Error in event handler")
    for handler in dead:
        self.unsubscribe(handler)

This means the BLE side never has to care whether anyone is listening. It publishes, and whatever is alive receives.

Two UIs, one process

NiceGUI serves the admin dashboard at / and the stream overlay at /overlay from the same Python process. These two pages have almost nothing in common. The admin page is a normal NiceGUI app with buttons, tables, dropdowns, form inputs. The overlay page is a transparent rectangle that OBS captures via browser source. It has no NiceGUI widgets at all, just an empty div and some injected JavaScript.

Making the overlay transparent is the easy part:

ui.add_css("""
    body, html {
        background: transparent !important;
        margin: 0; padding: 0; overflow: hidden;
    }
    .nicegui-content { padding: 0 !important; }
""")

OBS browser sources respect CSS transparency, so this creates a clean compositing layer over the camera feed. The overlay page loads a theme (an HTML file containing CSS and JS), splits it apart, injects both into the page, and registers a window.GoDiceTheme object that knows how to animate dice rolls.

Getting roll events into the right browser tab was the actual problem. NiceGUI runs on FastAPI and talks to each browser tab over its own WebSocket. Calling run_javascript() executes in whatever client context is active. But event handlers fire from the backend, outside any page context. Calling ui.run_javascript() from an EventBus subscriber without a context means NiceGUI does not know which tab to target.

The fix is to capture the client reference when the page loads:

client = context.client

async def show_roll(event: RollEvent):
    if not client.has_socket_connection:
        return
    settings = get_settings()
    data = json.dumps({
        "die_type": event.die_type.value,
        "value": event.value,
        "label": event.label,
        "color": event.color,
        "text_size": settings.text_size,
        "display_duration": settings.display_duration,
        "fake": event.stability == "fake",
        "suspicious": event.stability == "suspicious",
    })
    js = (
        f'if(window.GoDiceTheme){{window.GoDiceTheme.create('
        f'document.getElementById("{container_id}"),{data})}}'
    )
    try:
        client.run_javascript(js)
    except Exception as e:
        logger.debug("Failed to push roll to overlay: %s", e)

event_bus.subscribe(show_roll)

context.client grabs the NiceGUI client object for the current page request. That object is bound to one specific WebSocket connection, one specific browser tab. When show_roll closes over it, every future call to client.run_javascript() targets that exact overlay tab, even though the function runs from the backend event loop with no active page context.

Each overlay tab that connects gets its own show_roll closure with its own captured client. Opening /overlay in two browser tabs means both get roll events independently. The admin page subscribes its own handlers with its own client reference. No crosstalk.

client.has_socket_connection is the guard for stale connections. OBS reloads browser sources sometimes. I might close and reopen the overlay. When that happens, the WebSocket dies, this check returns False, and the handler silently skips the push. On the next roll, the EventBus will clean up the dead handler entirely (via the RuntimeError catch in publish). No error logs, no crashes, no intervention needed.

The payload itself is a JSON blob with everything the theme needs to render: die type, rolled value, label, color, text size, how long to display it, and whether the roll was flagged as fake or suspicious. The theme’s create() function takes a container element and this data, then handles its own animation and cleanup.

Fake rolls

The GoDice hardware has an accelerometer that classifies how a die reached its final position. The SDK does not just report the face value. It also reports whether the die was thrown and settled normally (STABLE), placed on the table by hand (FAKE_STABLE), or nudged after already being at rest (MOVE_STABLE). This matters for streaming. An accidental bump should not register as a real roll on the overlay.

The overlay has configurable handling for each type. Normal rolls always display. Fake rolls (placed by hand) default to being ignored entirely. Suspicious rolls (nudged dice) can be treated as normal, shown with a warning indicator, or ignored. All of this is toggled from the admin page. The hardware does the hard work of distinguishing a real throw from a placement, and the software just respects the classification.

Themes

The overlay supports swappable themes. Each theme is a self-contained HTML file with <style> and <script> blocks. The script registers a window.GoDiceTheme object with a create(container, data) method. When a roll comes in, the backend calls that function with the dice data. The theme owns all rendering and animation.

Four themes ship with the project:

  • Minimal does a simple scale-in and fade-out. Clean, stays out of the way.
  • Classic renders 3D beveled dice with that familiar tabletop look.
  • Parchment goes for a medieval fantasy aesthetic, parchment textures and serif fonts.
  • Neon is the most technically interesting one, with a glitch effect and a complementary color glow.

The neon color trick

I have always struggled to match colors properly, so instead of picking glow colors by hand I made the neon theme compute them. It takes whatever color the die is configured as and finds the complementary hue, the opposite point on the color wheel.

_complementaryNeon(hex) {
    const r = parseInt(hex.slice(1,3), 16) / 255;
    const g = parseInt(hex.slice(3,5), 16) / 255;
    const b = parseInt(hex.slice(5,7), 16) / 255;

    const max = Math.max(r, g, b), min = Math.min(r, g, b);
    let h = 0, s = 0, l = (max + min) / 2;
    if (max !== min) {
        const d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
        if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
        else if (max === g) h = ((b - r) / d + 2) / 6;
        else h = ((r - g) / d + 4) / 6;
    }

    h = (h + 0.5) % 1.0;  // shift hue by 180 degrees
    s = 1.0;               // max saturation
    l = 0.55;              // bright

    // ... HSL back to RGB (standard conversion)
}

Parse hex to RGB, convert to HSL, shift the hue 180 degrees, force full saturation and bright lightness, convert back. A red die gets a cyan glow. A blue die gets an orange glow. The result is applied as layered text-shadow on the roll value and pulses using the Web Animations API.

I will be honest: the neon theme is not quite what I had in my head. It works well enough, and it looks decent on stream, but it is one of those things I will probably revisit eventually.

Custom themes

Custom themes go into ~/.config/godice-overlay/themes/ and show up in the admin dropdown automatically. No build step, no framework. Just vanilla JS and CSS in a single HTML file.

Small touches

A few more things that round out the experience.

LED feedback. Each GoDice has a built-in RGB LED, and the overlay puts it to work. On a roll, the die pulses its configured color (or white, depending on the setting). There is also a crit mode: green pulse on a max roll (20 on a D20), red on a min (1 on a D20), white for everything else. Per-die overrides let me mix modes, so one die can run crit mode while another just pulses blue.

The admin page has an identify button that sends a quick pulse to a specific die. Useful when I have four dice on the desk and cannot remember which Bluetooth address maps to which UI entry. Low battery triggers a red LED, tied to the battery polling I mentioned earlier.

Settings persistence. All configuration lives in ~/.config/godice-overlay/config.yaml. Path resolution uses platformdirs for XDG compliance, so it lands in the right place on Linux, macOS, and Windows. Dice configs (address, type, label, color, LED mode), overlay settings (theme, position, duration, text size), and roll detection policies all persist across restarts.

Roll history. Every roll gets written to a SQLite database at ~/.local/share/godice-overlay/rolls.db. On startup, anything older than 30 days gets cleaned up automatically. Each session gets a UUID, so you can filter history by session. The admin page has a CSV export button for getting the data into a spreadsheet.

What I ended up with

The whole thing runs as a single Python process. FastAPI serves the admin page, the overlay page, and the WebSocket connections. Background tasks handle Bluetooth scanning, die communication, and battery polling. No external services, no database servers, no containers.

Using it looks like this: start the server, open the admin page in a browser, scan for dice, connect them. Configure colors, labels, and theme to taste. In OBS, add a browser source pointed at the overlay URL. Roll a die. It shows up on stream.

The code is on GitHub. MIT licensed, with one caveat: the GoDice Python SDK has a proprietary license that restricts usage to personal and academic purposes. Worth keeping in mind if the use case is commercial.

Some rough edges remain. The built-in themes could use more design work. Only one overlay session can connect at a time (multiple browser sources pointing at the same URL will fight over the WebSocket). The admin UI is functional but not pretty. It is a tool, not a product.

It is also on PyPI, so running it is just:

uvx godice-overlay

It does the thing I wanted it to do. I roll a die on my desk, and a second later it appears on stream with the right color, the right label, and a clean animation. That is all I needed.