Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

dice-rs is a Rust library and toolkit for controlling GoDice - physical Bluetooth dice that communicate over Bluetooth Low Energy (BLE).

Motivation

GoDice are physical dice with embedded LEDs, an accelerometer, and a BLE radio. They use the Nordic UART Service (NUS) profile for communication. The official APIs from Particula are in JavaScript and Python. dice-rs brings native Rust support with an idiomatic async API, type-safe domain model, and a trait-based transport layer for testability.

Scope

The workspace provides five crates:

  • dice-rs - core library with domain types, BLE transport, and a high-level service API
  • dice-rs-cli - command-line tool for quick interactions
  • dice-rs-controller - GTK 4 desktop application with 3D dice rendering
  • dice-rs-ws - WebSocket server for network-accessible dice events
  • yatzy - Kniffel (Yatzy) game with GTK 4 UI, AI opponent, and GoDice integration (see Yatzy)

Where to Get Help

Getting Started

Add the Dependency

Add dice-rs to your Cargo.toml:

[dependencies]
dice-rs = "0.1"
tokio = { version = "1", features = ["full"] }

Tokio Runtime

dice-rs is async-only. You need a tokio runtime to drive the BLE operations:

use dice_rs::{DiceManager, DiceEvent};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;

    if devices.is_empty() {
        println!("No GoDice devices found");
        return Ok(());
    }

    for device in &devices {
        println!("Found: {device}");
    }

    let dice = manager.connect(&devices[0]).await?;
    let mut receiver = dice.subscribe();
    while let Ok(event) = receiver.recv().await {
        match event {
            DiceEvent::Stable { face, .. } => {
                println!("Rolled: {face}");
                break;
            }
            DiceEvent::RollStart => println!("Rolling..."),
            DiceEvent::Disconnected => break,
            _ => {}
        }
    }

    dice.disconnect().await?;
    Ok(())
}

First Connection

  1. Create a DiceManager - this initializes the BLE adapter.
  2. Call scan() to discover nearby GoDice devices (filtered by GoDice_ prefix).
  3. Call connect() with a DiceDevice to establish a BLE connection.
  4. Call subscribe() to get a broadcast::Receiver<DiceEvent>.
  5. Listen for events in a loop.

The Dice handle is Clone, so you can share it across tasks. All clones share the same underlying connection state.

Architecture

Workspace Structure

dice-rs/
├── dice-rs/              # Core library
│   ├── src/
│   │   ├── ble/          # BLE transport layer (commands, events, transport trait)
│   │   ├── model/        # Domain types (FaceValue, LedColor, DiceType, etc.)
│   │   ├── service/      # High-level API (DiceManager, Dice, DiceEvent)
│   │   ├── error.rs      # Error types
│   │   └── lib.rs        # Re-exports
├── dice-rs-cli/          # CLI tool
├── dice-rs-controller/   # GTK 4 desktop app
├── dice-rs-ws/           # WebSocket server
└── games/
    └── yatzy/          # Kniffel (Yatzy) game
        ├── src/
        │   ├── models/   # Domain types (GameState, Scorecard, Player, etc.)
        │   ├── rules/    # Scoring rules, validation, cross-out advisor
        │   ├── services/ # GameController, DiceService, EventBridge, LED, etc.
        │   ├── strategy/ # ComputerAi, ExpectedValue, Probability
        │   ├── ui/       # GTK 4 application, window, widgets
        │   ├── i18n.rs   # Fluent internationalization
        │   ├── error.rs  # Error types
        │   └── lib.rs    # Re-exports

Module Layout

ble - Protocol Layer

Handles raw BLE communication: encoding commands, decoding notification events, and the BleTransport trait that abstracts the BLE backend.

  • command - Command enum encoding host-to-dice byte sequences
  • event - Event enum decoding dice-to-host notifications
  • transport - BleTransport and BlePeripheral traits
  • uuids - NUS service and characteristic UUIDs

model - Domain Types

Type-safe wrappers for GoDice data:

  • FaceValue - rolled face (1-based, rejects 0)
  • Acceleration - raw XYZ accelerometer data with face interpretation
  • LedColor - RGB color with named constants and hex parsing
  • BatteryLevel - 0–100 percent
  • ChargingState - charging or not charging
  • DiceColor - physical shell color (Black, Red, Green, Blue, Yellow, Orange)
  • DiceType - dice shell type (D6, D20, D10, D10X, D4, D8, D12)

service - High-Level API

  • DiceManager - manages BLE adapter and multiple dice connections
  • Dice - handle to a connected die with LED, battery, and event methods
  • DiceEvent - high-level events emitted via broadcast channel
  • DiceScanner - device discovery with name-prefix filtering

Data Flow

flowchart TD
    subgraph Dice["GoDice Hardware"]
        accel["Accelerometer"]
        ble_radio["BLE Radio"]
    end

    subgraph Transport["BLE Transport Layer"]
        btleplug["btleplug
        (BlueZ DBus)"]
        trait["BleTransport Trait"]
    end

    subgraph Service["Service Layer"]
        manager["DiceManager"]
        dice["Dice Handle"]
        channel["broadcast::Channel
        DiceEvent"]
    end

    subgraph Consumer["Application"]
        recv["Receiver"]
    end

    accel --> ble_radio
    ble_radio -->|NUS notifications| btleplug
    btleplug --> trait
    trait -->|Event parse| dice
    dice -->|DiceEvent| channel
    channel --> recv
    manager -->|connect/disconnect| trait

BLE Backend

The primary BLE backend is btleplug, which uses BlueZ DBus on Linux. The BleTransport trait abstracts the backend, enabling mock implementations for testing and future backends like bluer.

BLE Protocol

This chapter is the canonical BLE documentation for the project. The full protocol was reverse-engineered from the official JavaScript API and Python API source code.

The content below is included from docs/BLE.md so that the source of truth remains in a single file and the book stays in sync automatically.

BLE Specifications

BLE specification of the GoDice dice.

The dice are powered by the Nordic nRF52805 SoC - a Bluetooth 5.2 System-on-Chip with a 64 MHz ARM Cortex-M4 processor, 192 KB Flash, and 24 KB RAM. The nRF52805 is optimized for small two-layer PCB designs in a 2.48 x 2.46 mm WLCSP package. The SoC’s 64 MHz Cortex-M4 processes the dice’s 3D sensor data to calculate roll results and detect movement, tilting, free fall, and taps.

Particula selected the nRF52805 for its reliability and low power consumption. GoDice uses supercapacitor technology for ultra-fast battery-free charging - thanks in part to the ultra-low power characteristics of the Nordic SoC (4.6 mA in TX at 0 dBm, 4.6 mA in RX, and 0.3 uA in System OFF).

The dice use the Nordic UART Service (NUS) profile internally. NUS is a custom GATT service that emulates a serial port over BLE, originally designed by Nordic for UART-to-BLE bridging. GoDice repurposes it as a raw byte transport: the application protocol (opcodes and events) is layered on top of the NUS RX/TX characteristics.

The nRF52805 runs a SoftDevice S112 or S113

  • a memory-optimized Peripheral-only Bluetooth LE protocol stack suited to the SoC’s 24 KB RAM. Both stacks support up to 4 concurrent Peripheral connections with a Broadcaster, Bluetooth 5.1 qualification, 2 Mbps high-throughput, and Channel Selection Algorithm #2.

The full protocol was reverse-engineered from the official JavaScript API, Python API, and C API source code.

Device Properties

PropertyDescriptionValue
Device namePrefixGoDice_
Service UUIDNUS Service (16-bit offset 0x0001)6e400001-b5a3-f393-e0a9-e50e24dcca9e
Write CharacteristicNUS RX - host writes commands6e400002-b5a3-f393-e0a9-e50e24dcca9e
Notify CharacteristicNUS TX - dice sends notifications6e400003-b5a3-f393-e0a9-e50e24dcca9e

NUS Transport Details

  • Write type: Both Write Request (with response) and Write Command (without response) are supported on the RX characteristic
  • Notifications: The dice sends all data via Handle Value Notifications on the TX characteristic; the host must enable notifications by writing to the CCCD (Client Characteristic Configuration Descriptor, value 0x0001)
  • Security: All permissions are open (SEC_OPEN) - no pairing or bonding required
  • Max payload: MTU_SIZE - 3 bytes (20 bytes with the default 23-byte ATT MTU)
  • No encryption: Communication is unencrypted; the dice accept connections from any central

Byte Commands (Host → Dice)

All commands are written as byte arrays to the Write Characteristic. The first byte is always the opcode.

OpcodeDecimalCommandPayload BytesDescription
0x033Get Battery Level(none)Response: Bat + level byte
0x088Set LEDs[R1, G1, B1, R2, G2, B2] (6 bytes, 0–255)Sets both RGB LEDs; [0,0,0,0,0,0] turns off
0x1016Pulse LEDs[pulseCount, onTime, offTime, R, G, B, blinkMode, leds]onTime/offTime in units of 10 ms; max 255. blinkMode and leds select which LEDs blink
0x1420Stop Pulse LEDs(none)Stops any active pulse LED animation
0x1723Get Dice Color(none)Response: Col + color byte
0x1925Init[sensitivity, pulseCount, onTime, offTime, R, G, B, blinkMode, leds] (9 bytes)Initializes dice with sensitivity and LED configuration
0x3149Set Tap Interrupt[enable] (1 byte, 0=disable, 1=enable)Enables/disables single tap event notifications. Disabled by default.
0x3250Set Double Tap Interrupt[enable] (1 byte, 0=disable, 1=enable)Enables/disables double tap event notifications. Disabled by default.
0x65101Detection Settings[samplesCount, movementCount, faceCount, minFlatDeg, maxFlatDeg, weakStable, movementDeg, rollThreshold] (8 bytes)Updates roll detection sensitivity parameters

Receiving Events (Dice → Host)

The dice sends byte packets on state changes as notifications on the Notify Characteristic. The first byte determines the event type. Some events use ASCII prefixes for identification.

First Byte(s)ASCIIEventPayloadDescription
0x52RRollStart(none)Dice is currently rolling
0x53SStable[X, Y, Z] (3 signed bytes, offset 1)Dice is stable and flat; face derived from XYZ
0x46 0x53FSFakeStable[X, Y, Z] (3 signed bytes, offset 2)Stable after a “fake” roll; face derived from XYZ
0x54 0x53TSTiltStable[X, Y, Z] (3 signed bytes, offset 2)Stable but tilted (not flat); face derived from XYZ
0x4D 0x53MSMoveStable[X, Y, Z] (3 signed bytes, offset 2)Stable after small movement (face rotation); face derived from XYZ
0x42 0x61 0x74BatBatteryLevel[level] (1 byte, offset 3)Battery level response (0–100 percent)
0x43 0x6F 0x6CColDiceColor[color] (1 byte, offset 3)Dice color response
0x43 0x68 0x61 0x72CharCharging[charging] (1 byte, offset 4)Charging status (0 = not charging, 1 = charging)
0x54 0x61 0x70TapTap(none)Single tap detected (no payload)
0x44 0x54 0x61 0x70DTapDoubleTap(none)Double tap detected (no payload)

Dice Colors

ValueColor
0Black
1Red
2Green
3Blue
4Yellow
5Orange

Dice Types (Shells)

ValueTypeVector Table
0D6d6Vectors
1D20d20Vectors
2D10d20Vectors → d10Transform
3D10Xd20Vectors → d10XTransform
4D4d24Vectors → d4Transform
5D8d24Vectors → d8Transform
6D12d24Vectors → d12Transform

Note: D10X is also referred to as D100 (percentile) in the C API. The transform maps the D20 vector index to a D10 face value multiplied by 10 (i.e. d10_transform(roll) * 10), yielding values 0, 10, 20, …, 90.

setDieType is a client-side setting - no command is sent to the dice. Instead, it selects which vector table and transform to use when interpreting the XYZ accelerometer data to determine the face value.

Face Value Determination

The dice does not send the rolled number directly. Instead, it sends raw XYZ accelerometer data (3 signed 8-bit integers). The client determines the upper face by finding the closest vector in a pre-defined table:

  1. Extract [x, y, z] from the notification payload.
  2. Look up the vector table for the current DiceType.
  3. For each entry (face_value, reference_vector), compute the Euclidean distance: sqrt((x - rx)² + (y - ry)² + (z - rz)²). (The squared distance without sqrt is functionally equivalent for finding the minimum, since sqrt is monotonically increasing.)
  4. Return the face value with the smallest distance.
  5. If a shell transform applies (D10, D10X, D4, D8, D12), map the intermediate value through the transform table.

D6 Vector Table

FaceXYZ
1-6400
20064
30640
40-640
500-64
66400

D20 Vector Table

FaceXYZ
1-640-22
242-4240
3022-64
402264
5-42-4242
622640
7-42-42-42
8640-22
9-22640
1042-42-42
11-424242
1222-640
13-64022
14424242
15-22-640
164242-42
170-22-64
180-2264
19-4242-42
2064022

D24 Vector Table

FaceXYZ
120-60-20
220060
3-40-4040
4-60020
5402040
6-20-60-20
7206020
8-4020-40
9-404040
10-20060
11-20-6020
1260020
13-600-20
142060-20
15200-60
1640-20-40
17-2060-20
18-40-40-40
1940-2040
2020-6020
21600-20
224020-40
23-200-60
24-206020

Shell Transform Tables

Each transform maps the vector table index (1-based) to the final face value. D6 and D20 use identity (no transform). D10X multiplies the D10 transform by 10.

D4 Transform (D24 → D4)

Index0102030405060708
Face31414414
Index0910111213141516
Face23111423
Index1718192021222324
Face32224132

D8 Transform (D24 → D8)

Index0102030405060708
Face33612811
Index0910111213141516
Face47554425
Index1718192021222324
Face77828366

D10 Transform (D20 → D10)

Index01020304050607080910
Face8261439075
Index11121314151617181920
Face5709341628

D10X Transform (D20 → D10X)

Index01020304050607080910
Face8020601040309007050
Index11121314151617181920
Face5070090304010602080

D12 Transform (D24 → D12)

Index0102030405060708
Face12345678
Index0910111213141516
Face91011121234
Index1718192021222324
Face56789101112

Scanning & Connecting

DiceScanner

DiceScanner discovers GoDice devices by scanning for BLE peripherals with the GoDice_ name prefix. It wraps the BleTransport trait and provides configurable scan duration.

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;

    for device in &devices {
        println!("Found: {device}");
    }

    Ok(())
}

DiceManager

DiceManager is the entry point for all BLE operations. It manages the BLE adapter and provides methods for scanning, connecting, and disconnecting.

Multi-Dice Connections

You can connect to multiple dice concurrently. Each Dice handle is independent and has its own event channel:

use dice_rs::DiceManager;
use dice_rs::DiceEvent;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;

    let mut dice_list = Vec::new();
    for device in &devices {
        let dice = manager.connect(device).await?;
        dice_list.push(dice);
    }

    // Each dice has its own event receiver
    for dice in &dice_list {
        let mut receiver = dice.subscribe();
        // Spawn a task per dice to listen for events
        let name = dice.name().to_string();
        tokio::spawn(async move {
            while let Ok(event) = receiver.recv().await {
                println!("{name}: {event}");
            }
        });
    }

    Ok(())
}

Connection Retry

DiceManager::connect() retries up to 3 times with 1-second backoff. This handles transient connection failures, such as a GoDice that is advertising but not yet accepting connections while charging from 0% battery.

Reconnect

DiceManager::reconnect() attempts to reconnect a disconnected dice with exponential backoff (500ms → 5s, up to 10 retries).

Find by Address

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let dice = manager.connect_by_address("AA:BB:CC").await?;
    println!("Connected to {}", dice.name());
    Ok(())
}

The address parameter accepts a partial MAC address - the first device whose address contains the given substring is selected.

Disconnect

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    // Disconnect all connected GoDice devices
    let count = manager.disconnect_all().await?;
    println!("Disconnected {count} dice");
    Ok(())
}

Dice Events

Event Types

DiceEvent is the high-level event enum emitted by a connected GoDice. Events are delivered via a tokio::sync::broadcast channel.

VariantDescription
RollStartDice has started rolling
Stable { face, acceleration }Dice is stable and flat after a roll
TiltStable { face, acceleration }Stable but tilted
FakeStable { face, acceleration }Stable after a fake roll
MoveStable { face, acceleration }Stable after small movement
Charging { state }Charging status changed
TapSingle tap detected (must be enabled)
DoubleTapDouble tap detected (must be enabled)
DisconnectedBLE link lost or dice disconnected

Subscribing to Events

use dice_rs::{DiceManager, DiceEvent};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    let mut receiver = dice.subscribe();
    while let Ok(event) = receiver.recv().await {
        match event {
            DiceEvent::Stable { face, acceleration } => {
                println!("Stable on face {face} (accel: {acceleration:?})");
            }
            DiceEvent::RollStart => println!("Rolling..."),
            DiceEvent::TiltStable { face, .. } => println!("Tilt stable: {face}"),
            DiceEvent::FakeStable { face, .. } => println!("Fake stable: {face}"),
            DiceEvent::MoveStable { face, .. } => println!("Move stable: {face}"),
            DiceEvent::Charging { state } => println!("Charging: {state}"),
            DiceEvent::Tap => println!("Tap!"),
            DiceEvent::DoubleTap => println!("Double tap!"),
            DiceEvent::Disconnected => {
                println!("Disconnected");
                break;
            }
        }
    }

    Ok(())
}

Multiple Subscribers

The broadcast channel supports multiple subscribers. Each call to subscribe() returns an independent receiver:

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    let mut rx1 = dice.subscribe();
    let mut rx2 = dice.subscribe();

    // Both receivers get the same events
    tokio::spawn(async move {
        while let Ok(event) = rx1.recv().await {
            println!("Subscriber 1: {event}");
        }
    });

    while let Ok(event) = rx2.recv().await {
        println!("Subscriber 2: {event}");
    }

    Ok(())
}

Accelerometer Data

Stable, TiltStable, FakeStable, and MoveStable events include an Acceleration struct with raw XYZ accelerometer data (three i8 values). The face value is computed by finding the closest reference vector in the dice type’s vector table. See BLE Protocol for the full vector tables.

Tap Events

Tap and double tap notifications are disabled by default. Enable them explicitly:

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    dice.enable_tap().await?;
    dice.enable_double_tap().await?;

    let mut receiver = dice.subscribe();
    while let Ok(event) = receiver.recv().await {
        println!("{event}");
    }

    Ok(())
}

LED Control

GoDice has two RGB LEDs. The dice-rs library provides methods for setting colors and pulse animations.

LedColor

LedColor is an RGB color with channels 0–255:

#![allow(unused)]
fn main() {
use dice_rs::LedColor;

let red = LedColor::RED;
let custom = LedColor::new(128, 64, 255);
let from_hex = LedColor::from_hex(0xFF8800);
let off = LedColor::OFF;
}

Set LEDs

use dice_rs::{DiceManager, LedColor};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    // Set both LEDs to the same color
    dice.set_led(LedColor::GREEN).await?;

    // Set each LED independently
    dice.set_leds(LedColor::RED, LedColor::BLUE).await?;

    // Turn off
    dice.turn_off_leds().await?;

    Ok(())
}

Debouncing

set_leds() uses a debounce mechanism: rapid successive calls within a 30ms window are coalesced into a single BLE write. Only the most recent colors are written. This prevents BlueZ/DBus socket buffer overflow when an application fires many color changes in quick succession.

For one-shot commands where coalescing is undesirable, use set_leds_immediate():

use dice_rs::{DiceManager, LedColor};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    // Write immediately, bypassing debounce
    dice.set_leds_immediate(LedColor::RED, LedColor::RED).await?;

    Ok(())
}

Pulse LEDs

Pulse animations blink the LEDs with configurable timing:

use dice_rs::{DiceManager, LedColor};
use dice_rs::model::led::PulseBlinkMode;
use dice_rs::model::led::PulseLeds;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    // Pulse 3 times: 500ms on, 200ms off, green, solid color, both LEDs
    dice.pulse_leds(3, 50, 20, LedColor::GREEN, PulseBlinkMode::Color, PulseLeds::Both).await?;

    // Convenience: single pulse
    dice.pulse_once(50, 20, LedColor::RED).await?;

    Ok(())
}

on_time and off_time are in units of 10ms. Maximum value is 255 (2.55s).

PulseBlinkMode

ModeDescription
ColorSolid color blink
RainbowRainbow color cycle

PulseLeds

ValueDescription
BothBoth LEDs pulse
Led1Only LED 1 pulses
Led2Only LED 2 pulses

Battery & Status

Battery Level

Query the battery level (0–100 percent):

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    let battery = dice.get_battery_level().await?;
    println!("Battery: {battery}");

    Ok(())
}

The request uses a oneshot channel with a 5-second timeout. If the dice does not respond, DiceError::ResponseTimeout is returned.

Dice Color

Query the physical shell color:

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    let color = dice.get_color().await?;
    println!("Color: {color}");

    Ok(())
}

Charging State

The charging state is updated automatically from notifications. Query the last known state without sending a BLE command:

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    let charging = dice.charging_state();
    println!("Charging: {charging}");

    Ok(())
}

Charging state changes are also delivered as DiceEvent::Charging events.

RSSI

Query the signal strength (if available):

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    if let Some(rssi) = dice.rssi().await? {
        println!("RSSI: {rssi} dBm");
    }

    Ok(())
}

RSSI availability depends on the Bluetooth adapter and BlueZ version.

System Status

Get all status information in a single call:

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    let status = dice.system_status().await?;
    println!("Battery: {}", status.battery_level);
    println!("Color: {}", status.color);
    println!("Connected: {}", status.connected);
    if let Some(rssi) = status.rssi {
        println!("RSSI: {rssi} dBm");
    }

    Ok(())
}

system_status() performs battery level and color queries concurrently for efficiency.

Calibration

Software Calibration

Software calibration computes an AccelerationOffset from the next Stable event. The offset is the difference between the measured acceleration and the expected ideal gravity vector for the current dice type. The offset is then subtracted from all subsequent accelerometer readings before face value interpretation.

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    println!("Place the dice on a flat surface, then press Enter...");
    // In a real app, wait for user input

    let offset = dice.calibrate_software().await?;
    println!("Calibration offset: {offset:?}");

    Ok(())
}

After calibration, all subsequent Stable, TiltStable, FakeStable, and MoveStable events use the corrected acceleration data for face value determination.

Clear Calibration

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    dice.calibrate_software().await?;
    // ... later ...
    dice.clear_software_calibration()?;

    Ok(())
}

Hardware Calibration

Hardware calibration sends a BLE command (opcode 0x13) to the dice. The exact byte encoding is unconfirmed - this method is tentative and may not work with all firmware versions.

use dice_rs::DiceManager;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manager = DiceManager::new().await?;
    let devices = manager.scan().await?;
    let dice = manager.connect(&devices[0]).await?;

    dice.calibrate().await?;
    println!("Hardware calibration complete");

    Ok(())
}

If the dice reports a calibration failure, DiceError::CalibrationFailed is returned. If the dice does not respond within 5 seconds, DiceError::ResponseTimeout is returned.

CLI Tool

The dice-rs-cli crate provides a command-line tool (dice-rs) for interacting with GoDice devices without writing code.

Installation

cargo install dice-rs-cli

Commands

Scan

Scan for nearby GoDice devices:

dice-rs scan 5

The argument is the scan duration in seconds.

Listen

Connect to a dice and print events:

dice-rs listen AA:BB:CC:DD:EE:FF d6

The first argument is the MAC address (or a partial prefix). The second is the dice type (d6, d20, d10, d10x, d4, d8, d12).

Battery

Query battery level:

dice-rs battery AA:BB:CC:DD:EE:FF

LED Control

Set LED colors:

# Set both LEDs to red
dice-rs led AA:BB:CC:DD:EE:FF set red

# Set each LED independently
dice-rs led AA:BB:CC:DD:EE:FF set-dual red blue

# Pulse animation
dice-rs led AA:BB:CC:DD:EE:FF pulse green

# Turn off
dice-rs led AA:BB:CC:DD:EE:FF off

Tap Interrupts

Enable or disable tap notifications:

dice-rs tap AA:BB:CC:DD:EE:FF true
dice-rs double-tap AA:BB:CC:DD:EE:FF true

Calibration

dice-rs calibrate AA:BB:CC:DD:EE:FF

Status

Get comprehensive status:

dice-rs status AA:BB:CC:DD:EE:FF

Color

Query the dice shell color:

dice-rs color AA:BB:CC:DD:EE:FF

Charging

Query charging state:

dice-rs charging AA:BB:CC:DD:EE:FF

Disconnect

# Disconnect a specific dice
dice-rs disconnect AA:BB:CC:DD:EE:FF

# Disconnect all connected dice
dice-rs disconnect-all

Interactive Mode

Start a REPL for interactive exploration:

dice-rs interactive

Available commands in interactive mode: scan, connect <address>, disconnect, battery, color, charging, led <color>, status, calibrate, quit.

Controller

The dice-rs-controller crate is a GTK 4 desktop application for managing GoDice devices with a graphical interface and 3D dice rendering.

Features

  • Auto-scan on startup with manual rescan button
  • Dice list showing all connected dice with face value, battery, and color
  • Dice type selector - dropdown to choose between D6, D20, D10, D10X, D4, D8, D12
  • 3D dice rendering using OpenGL (glow + glam) with real-time orientation from accelerometer data and per-type geometry
  • LED controls - color pickers, set/pulse/off buttons
  • Tap controls - enable/disable tap and double tap notifications
  • Battery indicator with visual gauge
  • Roll history showing recent face values
  • Tap indicator flashing on tap events
  • Reconnect support for dropped connections

Building

The controller requires GTK 4 development libraries:

# Ubuntu/Debian
sudo apt install libgtk-4-dev

# Build
cargo build -p dice-rs-controller

Running

cargo run -p dice-rs-controller

The application window appears with a scan button and an empty dice list. Connected dice appear as rows with interactive controls.

Architecture

flowchart TB
    app["Application
    (gtk4::Application)"]
    window["MainWindow
    (scan, dice list)"]
    row["DiceRow
    (per-dice UI)"]
    controller["EventController
    (async → GTK bridge)"]
    dice3d["Dice3D
    (OpenGL renderer)"]
    models["Models
    (DiceModelTrait)"]
    led["LedControls
    (color pickers)"]
    tap["TapControls
    (tap switches)"]
    battery["BatteryIndicator"]
    history["RollHistory"]
    face["FaceDisplay"]
    typesel["DropDown
    (dice type)"]

    app --> window
    window --> row
    row --> controller
    row --> dice3d
    dice3d --> models
    row --> led
    row --> tap
    row --> battery
    row --> history
    row --> face
    row --> typesel
    typesel -->|set_dice_type| dice3d

EventController

The EventController bridges async dice events into the GTK main loop. It runs a background tokio task that receives DiceEvents and sends UI updates through an mpsc channel to the GTK main thread. This avoids blocking the UI while waiting for BLE events.

Dice Type Selection

Each DiceRow includes a DropDown widget for selecting the dice type (D6, D20, D10, D10X, D4, D8, D12). Changing the selection calls dice.set_dice_type() on the dice-rs library handle (client-side setting, no BLE command) and updates the 3D model geometry via Dice3D::set_dice_type().

The dice type controls how accelerometer data is interpreted into face values using vector tables and shell transforms. See Architecture for details on the interpretation pipeline.

3D Dice Models

The DiceModelTrait defines the interface for 3D geometry generation. Each dice type has a dedicated model implementation:

TypeShapeFacesVertices
D6Cube6 quads24
D4Tetrahedron4 triangles12
D8Octahedron8 triangles24
D10/D10XPentagonal trapezohedron10 kites60
D12Dodecahedron12 pentagons72
D20Icosahedron20 triangles60

The model_for_type() factory function selects the appropriate model based on DiceType. The DiceRenderer uploads vertex data (positions, normals, UVs, face IDs) to OpenGL and renders with diffuse lighting and edge highlighting. D6 faces additionally render procedural pips via the fragment shader; other die types show face color with edges only.

When the dice type changes at runtime, the Dice3D widget discards the existing renderer and re-initializes with the new model geometry on the next render frame.

WebSocket Server

The dice-rs-ws crate provides a WebSocket and REST server for exposing GoDice events over a network API.

Running

cargo run -p dice-rs-ws

The server starts on 0.0.0.0:3000.

REST API

Scan

POST /api/scan

Returns a JSON array of discovered devices:

[
  {
    "address": "AA:BB:CC:DD:EE:FF",
    "name": "GoDice_AAABBCC_O_v04",
    "rssi": -45
  }
]

Connect

POST /api/connect
Content-Type: application/json

{
  "address": "AA:BB:CC:DD:EE:FF",
  "dice_type": "d6"
}

Returns a session ID:

{
  "session_id": "abc123"
}

dice_type is optional. Accepts: d6, d20, d10, d10x, d4, d8, d12.

Disconnect

POST /api/disconnect
Content-Type: application/json

{
  "session_id": "abc123"
}

LED Control

POST /api/led
Content-Type: application/json

{
  "session_id": "abc123",
  "led1": "FF0000",
  "led2": "00FF00"
}

Battery

GET /api/battery/:session_id

Status

GET /api/status/:session_id

Calibration

POST /api/calibrate
Content-Type: application/json

{
  "session_id": "abc123"
}

WebSocket API

Connect to ws://localhost:3000/ws to receive a real-time stream of DiceEvents as JSON:

{
  "kind": "Stable",
  "face": 6,
  "acceleration": { "x": 64, "y": 0, "z": 0 }
}
{
  "kind": "RollStart"
}
{
  "kind": "Disconnected"
}

Deployment

The server uses axum with tokio. For production deployment, consider running behind a reverse proxy (e.g. nginx) with TLS termination.

Yatzy (Kniffel)

The yatzy crate is a full Kniffel (Yatzy) game for GoDice, built with GTK 4 and the dice-rs library. It demonstrates a complete game loop with physical dice integration: scan, connect, roll, score, and celebrate.

Features

  • Full Kniffel scorecard with all 13 categories in upper and lower sections
  • Game state machine with turn phases: AwaitingRoll, Rolling, Holding, Scoring, TurnEnd
  • Single-player mode with a computer AI opponent using expected-value calculations for optimal decisions
  • Multi-player pass-and-play mode with turn transitions and hints disabled for fairness
  • Strategy hints showing the best category and best hold decision via expected value analysis (single-player mode only)
  • Cross-out advisor recommending the least-damaging category when no valid score is possible
  • LED celebration effects for special rolls: Yatzy, Full House, Large Straight, Small Straight, Four-of-a-Kind
  • LED color assignment per player for visual identification on the physical dice
  • Dice slot mapping and roll detection for GoDice integration
  • Reconnection manager for handling dropped dice connections during gameplay
  • Persistent highscore list stored as JSON (top 20 entries)
  • Persistent game settings (player names, colors, types, game mode)
  • Internationalization with Fluent, German and English translations

Building

The game requires GTK 4 development libraries, same as the controller:

# Ubuntu/Debian
sudo apt install libgtk-4-dev

# Build
cargo build -p yatzy

Running

cargo run -p yatzy

The application starts with a setup screen for configuring players (names, colors, human/computer type) and selecting the game mode. After setup, scan for GoDice devices and connect. Each player rolls the physical dice, holds dice between rolls, and enters scores in the scorecard.

Game Flow

flowchart TD
    setup["Setup Screen
    (player config, game mode)"]
    scan["Dice Scanning
    & Connection"]
    awaiting["AwaitingRoll
    (player starts rolling)"]
    rolling["Rolling
    (dice in motion)"]
    holding["Holding
    (dice stable, decide holds)"]
    scoring["Scoring
    (must enter or cross out)"]
    turnend["TurnEnd
    (advance to next player)"]
    gameover["GameOver
    (final standings)"]

    setup --> scan
    scan --> awaiting
    awaiting --> rolling
    rolling --> holding
    holding -->|roll again| rolling
    holding -->|score early| scoring
    holding -->|3 rolls used| scoring
    scoring --> turnend
    turnend -->|rounds remaining| awaiting
    turnend -->|all rounds done| gameover

Turn Phases

PhaseDescription
AwaitingRollWaiting for the player to start rolling
RollingDice are physically rolling (RollStart received, no Stable yet)
HoldingDice are stable; player can hold dice and roll again, or proceed to scoring
ScoringPlayer must select a category to enter or cross out
TurnEndScore has been entered, transitioning to the next player

Each player has up to 3 rolls per turn. After the 3rd roll, the player must enter a score or cross out a category.

Scorecard Categories

The 13 categories are divided into an upper and lower section:

Upper Section

CategoryRuleScore
OnesSum of all 1sCount × 1
TwosSum of all 2sCount × 2
ThreesSum of all 3sCount × 3
FoursSum of all 4sCount × 4
FivesSum of all 5sCount × 5
SixesSum of all 6sCount × 6

If the upper section total reaches 63 or more, a bonus of 35 points is awarded.

Lower Section

CategoryRuleScore
Three-of-a-KindAt least 3 dice with same valueSum of all dice
Four-of-a-KindAt least 4 dice with same valueSum of all dice
Full House3 of one value + 2 of another25
Small Straight4 consecutive values30
Large Straight5 consecutive values40
YatzyAll 5 dice same value50
ChanceAny combinationSum of all dice

Computer AI

The ComputerAi decision engine uses expected value (EV) calculations to make optimal decisions:

  1. Roll or score: Compares the current best category score against the EV of re-rolling. If the current score meets or exceeds the EV, the AI enters the score; otherwise, it re-rolls.
  2. Hold decision: Enumerates all 32 possible hold masks (2^5) and selects the one with the highest EV across all empty categories.
  3. Category selection: When scoring, picks the empty category with the highest score.
  4. Cross-out: When no positive score is possible, the CrossOutAdvisor recommends the category with the lowest potential score to minimize lost points.

The EV calculation enumerates all possible re-roll outcomes (6^n where n is the number of re-rolled dice) and accumulates the expected score for each empty category.

Strategy Hints

In single-player mode, strategy hints are shown to help the player learn optimal play:

  • Best category hint: Shows which category would yield the highest score with the current dice.
  • Best hold hint: Shows which dice to hold for the highest EV on re-roll.

Hints are disabled in multi-player mode to ensure fair play between human players.

LED Effects

The game uses the physical dice LEDs for feedback:

EventEffect
Active player’s turnAll dice glow in the player’s color
Held diceHeld dice glow green, others off
Yatzy (5 of a kind)5 green pulses on all dice
Full House3 yellow pulses on all dice
Large Straight3 cyan pulses on all dice
Small Straight2 cyan pulses on all dice
Four-of-a-Kind2 orange pulses on all dice
Turn end / scoringLEDs turned off

Architecture

flowchart TB
    subgraph UI["UI Layer (GTK 4)"]
        app["Application"]
        window["MainWindow
        (setup, game, game-over screens)"]
        widgets["Widgets
        (scorecard, dice, hints, etc.)"]
    end

    subgraph Services["Service Layer"]
        controller["GameController
        (game flow, validation)"]
        dice_service["DiceService
        (BLE dice management)"]
        event_bridge["EventBridge
        (async → GTK bridge)"]
        roll_detector["RollDetector
        (roll/stable tracking)"]
        led_service["LedService
        (LED effect → BLE)"]
        reconn["ReconnectionManager"]
    end

    subgraph Domain["Domain Layer"]
        state["GameState
        (players, rounds, phases)"]
        scorecard["Scorecard
        (13 categories)"]
        scoring["Scoring Rules"]
        validation["Validation"]
        cross_out["CrossOutAdvisor"]
    end

    subgraph Strategy["Strategy Layer"]
        ai["ComputerAi
        (EV-based decisions)"]
        ev["ExpectedValue
        (hold/category analysis)"]
        prob["Probability"]
    end

    subgraph DiceRS["dice-rs"]
        manager["DiceManager"]
        dice["Dice Handle"]
    end

    app --> window
    window --> widgets
    widgets --> controller
    controller --> state
    state --> scorecard
    controller --> scoring
    controller --> validation
    controller --> cross_out
    controller --> ai
    ai --> ev
    ev --> prob
    controller --> led_service
    led_service --> dice_service
    dice_service --> manager
    manager --> dice
    event_bridge --> controller
    event_bridge --> dice_service
    roll_detector --> event_bridge
    reconn --> dice_service

GameController

The GameController orchestrates the game flow. It wraps GameState and validates all TurnActions against the current state before executing them. Actions that are invalid for the current phase or game status return an error. The controller emits ControllerEvents via a broadcast channel that the UI layer subscribes to.

EventBridge

The EventBridge bridges async dice events from dice-rs into the GTK main loop. It runs a background tokio task that receives DiceEvents, feeds them to the RollDetector for roll/stable tracking, and sends UI updates through a channel to the GTK main thread.

RollDetector

The RollDetector tracks the state of each physical die across roll events. It maps RollStart and Stable events from individual dice to a unified roll state, accounting for dice that may report at slightly different times. Once all dice in a slot are stable, it produces a DiceSet for the game controller.

Module Structure

games/yatzy/src/
├── models/          # Domain types (GameState, Scorecard, Player, etc.)
├── rules/           # Scoring rules, validation, cross-out advisor
├── services/        # GameController, DiceService, EventBridge, LED, etc.
├── strategy/        # ComputerAi, ExpectedValue, Probability
├── ui/              # GTK 4 application, window, widgets
├── i18n.rs          # Fluent internationalization
├── error.rs         # YatzyError types
└── lib.rs           # Re-exports

Internationalization

The game uses the Fluent localization system via i18n-embed. The fallback language is German (de), with English translations provided as well. Translation files are located in games/yatzy/i18n/{lang}/yatzy.ftl.

Platform Notes

Linux (BlueZ)

dice-rs uses btleplug which communicates with BlueZ via DBus on Linux. This is the primary and currently only supported platform.

Requirements

  • BlueZ 5.x
  • bluetoothd daemon running
  • DBus session bus access
  • A Bluetooth adapter with BLE support

Setup

# Check BlueZ is running
systemctl status bluetooth

# Start if needed
sudo systemctl start bluetooth

# Ensure the user has Bluetooth access
sudo usermod -aG bluetooth $USER
# Log out and back in for group changes to take effect

Permissions

The user running dice-rs needs DBus access to the Bluetooth adapter. In most desktop environments this is granted automatically. On headless servers or containers, you may need to:

  1. Start a DBus session: export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
  2. Ensure bluetoothd is running with --experimental flag if using extended BLE features.

Troubleshooting

“No Bluetooth adapter found”

Ensure bluetoothd is running and an adapter is present:

hciconfig -a

“Device not found during scan”

  • Make sure the GoDice is awake (tap or roll to wake)
  • Check that the dice is not already connected to another host
  • Try disconnecting stale connections: dice-rs disconnect-all
  • BlueZ does not deliver RSSI advertisement updates for connected devices. Disconnect before scanning.

“Connection failed after 3 retries”

  • The dice may be charging from 0% battery and not yet accepting connections. Wait a few minutes and retry.
  • Move closer to the Bluetooth adapter.
  • Check for interference from other BLE devices.

“BlueZ/DBus socket buffer overflow”

This can happen when sending many LED commands in rapid succession. The set_leds() method includes a 30ms debounce to prevent this. If using set_leds_immediate(), throttle your calls.

macOS

dice-rs supports macOS via btleplug’s CoreBluetooth backend. The library (dice-rs), CLI (dice-rs-cli), and WebSocket server (dice-rs-ws) are fully supported. The GTK4 controller (dice-rs-controller) is Linux-only.

Requirements

  • macOS 12 (Monterey) or later
  • A Bluetooth adapter with BLE support (built-in on all modern Macs)
  • Xcode Command Line Tools: xcode-select --install

Permissions

macOS requires Bluetooth permission for the process. When running the CLI or WebSocket server for the first time, the OS will prompt for Bluetooth access. Grant the permission to your terminal or application.

Troubleshooting

“No Bluetooth adapter found”

Ensure Bluetooth is enabled in System Settings > Bluetooth.

“Device not found during scan”

  • Make sure the GoDice is awake (tap or roll to wake)
  • Check that the dice is not already connected to another host
  • CoreBluetooth may cache device state — try toggling Bluetooth off/on

Windows

dice-rs supports Windows via btleplug’s WinRT backend. The library (dice-rs), CLI (dice-rs-cli), and WebSocket server (dice-rs-ws) are fully supported. The GTK4 controller (dice-rs-controller) is Linux-only.

Requirements

Permissions

Windows requires Bluetooth access for the process. No explicit permission prompt is shown, but the Bluetooth radio must be enabled in Settings > Bluetooth & devices.

Troubleshooting

“No Bluetooth adapter found”

Ensure Bluetooth is enabled in Settings > Bluetooth & devices and that a compatible adapter is present.

“Device not found during scan”

  • Make sure the GoDice is awake (tap or roll to wake)
  • Check that the dice is not already connected to another host
  • WinRT may not deliver RSSI updates for all devices — the library falls back to cached properties when read_rssi() fails

“Connection failed after 3 retries”

  • The dice may be charging from 0% battery and not yet accepting connections. Wait a few minutes and retry.
  • Move closer to the Bluetooth adapter.
  • Check for interference from other BLE devices.

Controller (Linux-only)

The dice-rs-controller GTK4 desktop application is Linux-only. It depends on GTK4, OpenGL (glow), and BlueZ-specific behaviors. There are no plans to port it to Windows or macOS. For cross-platform UI access, use the dice-rs-ws WebSocket server with a web-based or native client.