Many of us fondly remember the joy of playing board games like Snakes and Ladders or Ludo during our childhood. These simple yet engaging games brought families and friends together, making any gathering lively and entertaining. Rolling dice, moving tokens, and experiencing the ups and downs of the game taught us lessons in patience and unpredictability while sparking countless laughs and friendly competition. Those moments of flipping game boards in frustration or celebrating a lucky roll remain etched in our memories.
And as you may noticed nowadays many of us are addicted to modern games and may even forget these fun and entertaining board games. The thought of adding a modern twist to the classic games gave me the idea of creating a digital dice with some wireless connectivity. With some tinkering, I decided to opt for the BLE for the connectivity, because the low-power nature of it is really beneficial for such a project.
I decided to go with an ultra-low power microcontroller such as an NRF52840 since these are not only highly power efficient than the counterparts such as ESP32, but they also have superior support for development IDEs such as Arduino IDE. I chose the NINA-B306 module featuring the NRF52840 since it would be much easier to handle and we don’t need to worry about the antenna design headaches compared to using the bare nRF52840 chips. Combining BluetoothLE connectivity, advanced motion sensors, and LED-based visual feedback, the Smart LED Dice bridges the gap between nostalgic gaming and modern tech.
This project was made possible, thanks to our sponsors ALLPCB. The PCB boards used in this project were fabricated by allpcb, more info on how to order will be shared later in this article.
Features of our Smart Electronic Dice
Based on low power nRF52840 low power, multi-protocol Bluetooth 5 SoC.
Bluetooth V5 Low Energy connectivity. Pair with smartphones, tablets, or custom BLE-enabled devices.
Arduino Nano 33 BLE compatible.
MPU6050 IMU with Integrated accelerometer and gyroscope to detect dice orientation and movement for roll detection.
TP4056 Lithium-ion battery charging IC with overcharge protection.
Onboard USB type C port for charging and Programming.
LEDs for Face Indication.
RGB LED for status indications.
Custom PCB with a compact and optimized layout integrates all components.
Designed to fit within a dice form factor.
Real-time orientation detection using MPU6050 to determine the face of the dice after rolling.
LEDs light up to show the upward-facing number on the dice after a roll.
Android App support. Companion app to receive dice roll data and display results.
Components Required to Build the Smart LED Dice
The components required to build a BLE are listed below. The exact value of each component can be found in the schematics or the BOM.
NINA-B306-00B module
MPU6050 IMU
MIC5219-3.3 LDO
TP4056 Li-ion battery
1615 CA RGB LED
0805 LEDs
SMD resistors and capacitors
Connectors
Custom PCB
3D printed parts.
Other tools and consumables.
Smart LED Dice Schematic Diagram
The complete circuit diagram for the Smart LED Dice is shown below. It can also be downloaded in PDF format from the link given at the end.
Let’s discuss the Schematics section by section for better understanding. First, we have the power section, which includes the power input, battery charging and voltage regulation. A type C USB port is used for both charging as well as for programming purposes. The power from the USB port is connected to a power path controller circuit built around a P-Channel MOSFET U3 and a diode D1. This will allow us to power the board either from the USB input or from the battery without causing any issues. The battery charging circuit is built around the infamous TP4056 standalone linear Li-lon battery charge controller IC. It will take the 5V input from the USB port and will charge the internal battery. The TP4056 also provides two indicators, one for charging indication and one for full charge indication.
We have also connected voltage dividers to these indicator pins which can be used for monitoring the charging status. For converting the VBUS voltage from the power path controller to 3.3V we have used an MIC5219 ultra-low noise low drop out voltage regulator. With very minimal auxiliary components the MIC5219 provides a very stable output voltage even when the battery charge level is low.
Next, we have the Nina B306-00B module as the brain. The Nina B306-00B features the Nordic Semiconductor nRF52840 Bluetooth 5 Low Energy SoC, featuring an Arm Cortex-M4 processor with a floating-point unit, operating at 64 MHz. It integrates 1 MB of flash memory and 256 kB of RAM, offering ample space for code and data storage. For motion and orientation detection, we have used an MPU6050 IMU from InvenSense, which features a 3-axis gyroscope and a 3-axis accelerometer on the same silicon die, together with an onboard Digital Motion Processor that processes complex 6-axis MotionFusion algorithms. The MPU6050 is interfaced with the Nina B306 module via the I2C interface.
For indicating the Dice faces we have used LEDs. Each face will have a corresponding number of LEDs. Apart from the face with one LED, on all other faces, we are using 0805 LEDs connected in parallel with separate current limit resistors. For the face with one LED, we have used an RGB LED. This RGB is not only used for the face indication but also for indicating connectivity status. The RGB led with 1615 package made it the best choice for this without compromising on the size. It is small enough to fit with the aesthetics and easy to handle while assembling the circuit.
PCB for Smart LED Dice
For this project, we have decided to make a custom PCB. This will ensure that the final product is as compact as possible as well as easy to assemble and use. The PCB is designed with KiCad. All the design files are available to download from the GitHub repo linked below this article. The PCB has a dimension of each face is approximately 30mm x 30mm. And in total there are 6 such PCBs. But for manufacturing, we have created a panel with all 6 of them with size of 63x96mm, with stamp holes for easy separation.
Here is the top layer of the PCB.
The below image shows the bottom layer of the PCB.
And here is the 3D view of the PCB.
Ordering PCB from ALLPCB
Now after finalizing the design, you can proceed with ordering the PCB:
Step 1: Get into https://www.allpcb.com/?code=PT19 and sign up if this is your first time. Then, in the PCB Quote tab, click on the Advanced PCB Quote, upload the Gerber file, and the tool will automatically populate the PCB dimensions. Now make any changes you need such as PCB colour, Silkscreen, PCB thickness, and the number of PCBs etc.
Step 2: Once all the required parameters are set click on quote now to generate the quote. The tool will show the build time, Cost and shipping costs of different shipping methods. Select the appropriate shipping method and click on add cart to proceed with the order.
Step 3: In the cart select the PCB we have just added and click on proceed. Give the shipping and billing details and then proceed with the payment. Once the payment is done the ALLPCB will start manufacturing your order. Within a week, you will receive the finished PCB.
Assembling the Smart LED Dice
To assemble the SMART LED Dice, first assemble each PCB on the panel. Each PCB represents one face of the dice. One side will have the LEDs and the other side will have all other components. Here is the fully assembled PCB.
Here is the fully assembled Dice.
3D Printed Parts
We have designed a 3D-printed cover enclosure that will fit over the PCB. These parts will ensure that the sharp edge of the PCB dice won’t affect its movements and that it rolls smoothly. The files for all the 3D printed parts can be downloaded from the GitHub link provided at the end of the article along with the Arduino sketch and bitmap file. Learn more about 3D printing and how to get started with it by following the link. You can download the 3D files from Thingiverse or from the project GitHub repo.
Thingyverse : https://www.thingiverse.com/thing:6855807
Here are the 3D-printed parts.
And here is the fully assembled Smart LED Dice with the 3D enclosure.
How Does the Smart LED Dice Work?
When powered on, the dice enters a standby mode, awaiting a BLE connection. During this time, the integrated RGB LED flashes red, green, and blue in a loop to indicate it is ready to connect. Once a BLE device, such as a smartphone or similar, successfully connects, the RGB LED turns off, signalling the connection and placing the dice in an idle state, ready for user interaction. The dice is equipped with an MPU6050 motion sensor to detect the dice roll. When a motion is detected, all face LEDs illuminate sequentially in a rolling effect, simulating the dice being tossed. This dynamic pattern continues until the dice become stationary. Once stationary, the dice determine the upward-facing face using data from the motion sensor. The LEDs corresponding to the upward-facing face start to flash to indicate the result.
To communicate the result to the connected BLE device, the dice updates two BLE characteristics. The Status Characteristic indicates whether a new roll and valid face detection have occurred. It is set to 1 after a roll is detected and can be reset to 0 by the connected device, allowing the dice to prepare for the next roll. The Face Number Characteristic transmits the number that corresponds to the upward-facing face and remains unchanged until the next roll. Notifications are sent to the connected device whenever these characteristics are updated, prompting it to read the values. Once the connected device reads the face number and resets the status characteristic to 0, the dice become ready to detect the next shake or roll. This ensures a smooth interaction cycle, where the dice locks further roll detection until the connected device confirms it is ready for the next action.
BLE Dice Companion APP
To demonstrate the the functionalities of the Smart LED Dice we have created a simple smartphone app using the MIT App Inventor. The app has only the bare minimum components. There are two labels, one to show the connection status and one to show the result. There is an image element which is used to display the result graphically. We have added three buttons to the UI, of which two are for connecting and disconnecting the BLE connection. The reset button is used for debugging purposes, which will manually reset the status characteristics to zero.
Here is the block view for the app.
We have created four global variables to store the BLE device name, service UUID, status characteristics UUID and dice face characteristic UUID. As soon as the app is open, it will check if the necessary permissions are already granted or not. If not granted it will prompt to grant those permissions. Make sure you have turned on the Bluetooth and location from the device settings. Once all the permissions are granted all you have to do is click on the connect button. The app will automatically scan and connect to the Smart LED Dice using the device name and service UUID. If you want to disconnect the device you can either click on the disconnect button or close the app.
Once connected to the Smart LED Dice the app will register for notifications for the Dice face characteristics. This way, as soon as the face characteristics value is changed the app will get notified. Once notified the app will read this value and display it on the app screen. It will also display the corresponding image indicating the result. Once the value is read successfully the app will update the status characteristics with the value zero to enable the dice to detect the next roll.
Both the Android app installer file as well as the MIT app inventor project files can be found on the project GitHub repository link provided at the end of this tutorial. While using the MIT app inventor don’t forget to install the BluetoothLE extension if it is not already installed.
Arduino Code for Smart LED Dice
As now we are familiar with the hardware and the basic working principle, let’s look at the firmware part. Since we are using the NINA-B306 module, we can program it with the Arduino IDE since it’s the same module that is on the Arduino Nano 33 BLE. To start with we need to flash the Arduino Nano 33 BLE bootloader to the module. To do that connect the programming pads (SWDIO, SWCLK, GND, Resetand 3.3V to any ARM debugger such as CMSIS-DAP or JLINK. Then install the Arduino MBed OS Nano board in the Arduino IDE, if it was not already installed. Later connect the debugger to the PC, assuming you have already installed all the necessary drivers, in the Arduino IDE, select the Arduino Nano 33 BLE as the board and select the appropriate programmer from the tools menu. Then use the burn bootloader option from the tools menu. It will automatically flash the bootloader to the module. One another option is to download the bootloader binary and flash it using any of your favourite debug tools such as J-flash or J-link commander.
Once the bootloader is burned then, we can move forward with the coding. For that, either download and open the Arduino source file from the GitHub repository or just create a new sketch and copy and paste the code given below. Make sure to set the board as the Arduino Nano ## BLE and install all the necessary libraries that were mentioned in the code. Compile it and flash it to the board using the upload button. That's it, now you will be able to see a BluetoothLE device named BLE_Dice if you search for available Bluetooth devices.
Now let's look at the code itself. As usual,l we have included all the necessary libraries and defined all the necessary pins. Later we defined the global variables and also created an instance for the MPU6050 library. This instance will be used to communicate with the MPU6050 IMU. You can also see we have defined a few Bluetooth-related variables such as the device name, service UUID and characteristic UUIDs. The device name, as the name suggests, is used for naming the BLE device. This is the name that will appear if you can for a Bluetooth device. Keep in mind that this name is also hardcoded to the companion app, so if you decide to change the name you should change it in the app too. The service UUID is used for the BLE communication. The two characteristic UUIDs are used to send data between the dice and the connected device. As mentioned earlier one will have the status flag and the other will have the result.
#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <ArduinoBLE.h>
// Pin definitions
#define rgbRed A7
#define rgbGreen A6
#define rgbBlue 3
#define leftLEDs 4
#define rightLEDs 9
#define backLEDs 10
#define bottomLEDs 6
#define topLEDs 5
Adafruit_MPU6050 mpu;
// Variables for shake and stationary detection
float shakeThreshold = 15.0; // Adjust for shake sensitivity
float stationaryThreshold = 15.0; // Threshold for being stationary
unsigned long stationaryDuration = 1500; // Duration to confirm stationary (in ms)
// BLE Characteristics
BLEService diceService("180A");//0000180A-0000-1000-8000-00805f9b34fb
BLEByteCharacteristic statusCharacteristic("2A57", BLERead | BLEWrite | BLENotify);//00002A58-0000-1000-8000-00805f9b34fb
BLEByteCharacteristic faceCharacteristic("2A58", BLERead | BLENotify);//00002A57-0000-1000-8000-00805f9b34fb
// Other Variables
int currentFace = -1; // Tracks the current face
unsigned long lastStationaryTime = 0;
unsigned long lastRGBBlinkTime = 0;
bool BLEStatus = false;
int LEDPins[7] = {-1,A6, 4, 5, 6, 9,10};
enum DiceState {
WAIT_FOR_SHAKE,
WAIT_FOR_STATIONARY,
UPDATE_FACE,
WAIT_FOR_BLE_RESET
};
DiceState currentState = WAIT_FOR_SHAKE; // Start in the WAIT_FOR_SHAKE state
unsigned long lastFlashTime = 0; // For non-blocking sequential LED flashing
int flashIndex = 0; // Current LED index for sequential flashing
bool shakeDetected = false; // To track shake detection
unsigned long stationaryStartTime = 0; // Track time for stationary detection
Next in the setup function, we have initialised the IMU instance and also initialised all the GPIOs required. Once the sensor and the GIOs are successfully initialised the dice will then start the BLE service and start advertising.
void setup() {
Serial.begin(115200);
// Initialize MPU6050
if (!mpu.begin()) {
Serial.println("Failed to find MPU6050 chip");
while (1);
}
Serial.println("MPU6050 Found!");
mpu.setAccelerometerRange(MPU6050_RANGE_8_G);
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
// Initialize LED pins
pinMode(rgbRed, OUTPUT);
pinMode(rgbGreen, OUTPUT);
pinMode(rgbBlue, OUTPUT);
pinMode(leftLEDs, OUTPUT);
pinMode(rightLEDs, OUTPUT);
pinMode(backLEDs, OUTPUT);
pinMode(bottomLEDs, OUTPUT);
pinMode(topLEDs, OUTPUT);
turnOffAllLEDs(); // Ensure all LEDs are off at boot
digitalWrite(rgbRed, LOW);
// Initialize BLE
if (!BLE.begin()) {
Serial.println("Starting BLE failed!");
while (1);
}
BLE.setLocalName("BLE_Dice");
BLE.setAdvertisedService(diceService);
diceService.addCharacteristic(statusCharacteristic);
diceService.addCharacteristic(faceCharacteristic);
BLE.addService(diceService);
// Set characteristics to 0 at boot
statusCharacteristic.writeValue(0);
faceCharacteristic.writeValue(0);
BLE.advertise();
Serial.println("BLE_Dice is ready!");
}
The loop function is responsible for the connection management. If the connection is active the loop function will call the handleDiceLogic function. If the connection is inactive the loop function will flash the RGB LED indicating the connection status.
void loop() {
// Handle BLE
BLEDevice central = BLE.central();
if (central) {
BLEStatus = true;
Serial.print("Connected to: ");
Serial.println(central.address());
currentState = WAIT_FOR_SHAKE;
statusCharacteristic.writeValue(0); // Dice ready
faceCharacteristic.writeValue(0); // Detected face
turnOffAllLEDs();
while (central.connected()) {
handleDiceLogic();
}
Serial.println("Disconnected from central");
turnOffAllLEDs();
BLEStatus = false;
digitalWrite(rgbRed, LOW);
}
if(BLEStatus == false) {
blinkRGB();
}
}
The handleDiceLogic function is used to coordinate multiple subfunctions to create the dice logic. Once the connection is active the dice will be in the WAIT_FOR_SHAKE state. In this state, the dice will use the detectshake function to detect the motion and to determine whether it is valid or not. Once the shake or roll is detected the dice will change to WAIT_FOR_STATIONARY state. In this state, the dice will wait for its motion to be stopped and become stationary. The detectStationary function is used to detect whether the dice stopped rolling and stood still. Once the dice is still the, it will change to the UPDATE_FACE state. In this the dice orientation is determined with the help of the detectface function and the corresponding LEDs will start to flash. Then it will write the status value and the result to the BLE characteristics we have mentioned and will go to the WAIT_FOR_BLE_REST. As long as the the BLE device is connected the dice will remain in this state until the status value is reset by the BLE device. Once it is reset the dice will start the loop once again and will wait for the next roll.
void handleDiceLogic() {
switch (currentState) {
case WAIT_FOR_SHAKE:
// Blink blue LED until shake is detected
blinkBlueLED();
if (detectShake()) {
shakeDetected = true;
digitalWrite(rgbBlue, HIGH); // Turn off blue LED
currentState = WAIT_FOR_STATIONARY; // Move to next state
Serial.println("Shake detected! Moving to WAIT_FOR_STATIONARY.");
}
break;
case WAIT_FOR_STATIONARY:
// Flash LEDs sequentially until stationary
if (millis() - lastFlashTime >= 100) { // Non-blocking delay for LED flashing
flashSequentialLEDs();
lastFlashTime = millis();
}
if (detectStationary()) {
currentState = UPDATE_FACE; // Move to next state
Serial.println("Stationary detected! Moving to UPDATE_FACE.");
}
break;
case UPDATE_FACE:
// Determine face and update LEDs and BLE characteristics
currentFace = determineFace();
if(currentFace < 0)
{
return;
}
updateLEDs(currentFace);
// Set BLE characteristics
statusCharacteristic.writeValue(1); // Dice ready
faceCharacteristic.writeValue(currentFace); // Detected face
Serial.println("Face updated! Waiting for BLE reset.");
currentState = WAIT_FOR_BLE_RESET; // Move to next state
break;
case WAIT_FOR_BLE_RESET:
// Wait for user to set statusCharacteristic to 0
if (statusCharacteristic.value() == 0) {
turnOffAllLEDs(); // Reset LEDs before next cycle
currentState = WAIT_FOR_SHAKE; // Go back to initial state
Serial.println("BLE reset! Returning to WAIT_FOR_SHAKE.");
}
break;
}
}
bool detectShake() {
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
float magnitude = sqrt(a.acceleration.x * a.acceleration.x +
a.acceleration.y * a.acceleration.y +
a.acceleration.z * a.acceleration.z);
if (magnitude > shakeThreshold) {
Serial.println("Shake detected!");
return true;
}
return false;
}
bool detectStationary() {
static float accelSum = 0; // Sum of acceleration magnitudes
static int sampleCount = 0; // Number of samples in the averaging window
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
float magnitude = sqrt(a.acceleration.x * a.acceleration.x +
a.acceleration.y * a.acceleration.y +
a.acceleration.z * a.acceleration.z);
// Add current magnitude to the sum
accelSum += magnitude;
sampleCount++;
// Average the acceleration magnitude over the sample window
float averageMagnitude = accelSum / sampleCount;
if (averageMagnitude < stationaryThreshold) {
if (millis() - lastStationaryTime > stationaryDuration) {
// Reset for the next cycle
accelSum = 0;
sampleCount = 0;
Serial.println("Stationary detected!");
return true;
}
} else {
// Reset stationary timer if movement is above the threshold
lastStationaryTime = millis();
accelSum = 0;
sampleCount = 0;
}
return false;
}
int determineFace() {
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
if (a.acceleration.y > 8.0) return 3; // Top
if (a.acceleration.y < -8.0) return 4; // Bottom
if (a.acceleration.z > 8.0) return 5; // Right
if (a.acceleration.z < -8.0) return 2; // Left
if (a.acceleration.x > 8.0) return 6; // Back
if (a.acceleration.x < -8.0) return 1; // Front
return -1; // Unknown
}
The remaining functions are used for the LED controls and are called within the previously explained functions. The flashSequentialLEDs will flash the LEDs in each face in a sequence during the dice roll. The blnkRGB function is called when there is no active connection, and the updateLEDs are used to flash the LEDs to show the result. The turnOffAllLEDs function is used to turn off all the LEDs as mentioned.
void flashSequentialLEDs() {
const int leds[] = {rgbGreen,leftLEDs, rightLEDs, backLEDs, bottomLEDs, topLEDs};
for (int i = 0; i < 6; i++) {
turnOffAllLEDs();
if(i == 0)
{
digitalWrite(leds[i], LOW);
}
else {
digitalWrite(leds[i], HIGH);
}
delay(100);
}
}
void blinkBlueLED() {
static unsigned long lastBlinkTime = 0;
static bool ledState = false;
if (millis() - lastBlinkTime >= 500) {
ledState = !ledState;
digitalWrite(LEDPins[currentFace], ledState ? LOW : HIGH);
lastBlinkTime = millis();
}
}
void blinkRGB() {
if (millis() - lastRGBBlinkTime >= 200) {
if(digitalRead(rgbRed) == 0)
{
digitalWrite(rgbRed, HIGH);
digitalWrite(rgbBlue, HIGH);
digitalWrite(rgbGreen, LOW);
}
else if(digitalRead(rgbGreen) == 0)
{
digitalWrite(rgbRed, HIGH);
digitalWrite(rgbGreen, HIGH);
digitalWrite(rgbBlue, LOW);
}
else if(digitalRead(rgbBlue) == 0)
{
digitalWrite(rgbBlue, HIGH);
digitalWrite(rgbGreen, HIGH);
digitalWrite(rgbRed, LOW);
}
lastRGBBlinkTime = millis();
}
}
void updateLEDs(int face) {
turnOffAllLEDs();
if(face == 1)
{
digitalWrite(LEDPins[face], LOW);
}
else
{
digitalWrite(LEDPins[face], HIGH);
}
}
void turnOffAllLEDs() {
digitalWrite(rgbRed, HIGH); // Fully off for common anode
digitalWrite(rgbGreen, HIGH);
digitalWrite(rgbBlue, HIGH);
digitalWrite(leftLEDs, LOW);
digitalWrite(rightLEDs, LOW);
digitalWrite(backLEDs, LOW);
digitalWrite(bottomLEDs, LOW);
digitalWrite(topLEDs, LOW);
}
Supporting Files
Here is the link to our GitHub repo, where you'll find the source code, schematics, and all other necessary files to build your own Smart LED Dice.