OLED dice on an ESP32-C3
A die tumbling on a 128x64 OLED, thrown by holding an arcade button and slowing to a stop when you let go. The whole build, an ESP32-C3 and 2 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
IntermediateRuns in your browser. Free, and no account needed.
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// OLED dice: a rolling die on a 128x64 SSD1306, thrown with an arcade button.
//
// Wiring, board to modules:
// OLED SCL -> GPIO 5 SDA -> GPIO 4 VCC -> 3V3 GND -> GND
// button -> GPIO 3 and its other leg to GND
//
// Hold the 30 mm button down and the die tumbles; let go and it slows to a
// stop, the way a real one does. The face it lands on goes in the tally down
// the right hand side, so a few dozen throws show the distribution filling in.
//
// # The die
//
// A die face is seven possible pips on a three by three grid, and which of the
// seven are inked is a six-entry table of bitmasks. Drawing one is therefore a
// loop over seven circles and nothing else: no per-face code, and no way for
// a five to be drawn like a four.
//
// The face is 46 pixels square with a rounded border, which leaves the right
// third of the panel for the tally.
//
// # Slowing down
//
// While the button is held the die changes face every 60 ms, which is fast
// enough to read as a blur. On release the gap between changes grows by a
// twelfth each time, so the tumble decays geometrically and the last few faces
// are visibly separate: about a second and a half from release to rest, which
// is what a die thrown across a table takes.
//
// # Where the number comes from
//
// A sixteen-bit LFSR with taps at 16, 14, 13 and 11, stepped once per frame
// for as long as the button is held. The seed is therefore how long the player
// leant on it, in milliseconds, which is the only entropy a board with no clock
// battery and no radio actually has.
#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"
const uint8_t SCL_PIN = 5;
const uint8_t SDA_PIN = 4;
const uint8_t BUTTON_PIN = 3;
SoftI2c bus(SCL_PIN, SDA_PIN, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);
// The face, and the tally beside it.
const int16_t FACE_X = 4;
const int16_t FACE_Y = 9;
const int16_t FACE_W = 46;
const int16_t PIP_R = 4;
const int16_t TALLY_X = 60;
const int16_t TALLY_Y = 9;
const int16_t TALLY_W = 64;
const int16_t BAR_H = 7;
const int16_t BAR_GAP = 2;
// Which of the nine grid positions are inked, for each face. Bit n is
// position n, read left to right then top to bottom:
//
// 0 1 2
// 3 4 5
// 6 7 8
const uint16_t FACES[6] = {
0x010u, // 1: the middle -- 4
0x101u, // 2: a diagonal -- 0, 8
0x111u, // 3: the diagonal plus the middle: 0, 4, 8
0x145u, // 4: the four corners -- 0, 2, 6, 8
0x155u, // 5: the corners and the middle: 0, 2, 4, 6, 8
0x16du, // 6: two columns of three: 0, 2, 3, 5, 6, 8
};
uint8_t tally[6] = {0, 0, 0, 0, 0, 0};
uint8_t face = 0;
uint16_t lfsr = 0xbeefu;
static uint8_t roll() {
// Taps at 16, 14, 13 and 11: the maximal-length sixteen-bit LFSR.
uint16_t bit = (uint16_t)(((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1u);
lfsr = (uint16_t)((lfsr >> 1) | (bit << 15));
return (uint8_t)(lfsr % 6u);
}
/** Draw face `f` (0..5) inside the square border. */
static void drawFace(uint8_t f) {
oled.fillRect(FACE_X, FACE_Y, FACE_W, FACE_W, false);
oled.rect(FACE_X, FACE_Y, FACE_W, FACE_W, true);
// Nine positions on a three by three grid inset from the border.
const int16_t step = (int16_t)((FACE_W - 18) / 2);
uint16_t mask = FACES[f];
for (int16_t i = 0; i < 9; i++) {
if ((mask & (uint16_t)(1u << i)) == 0u) {
continue;
}
int16_t cx = (int16_t)(FACE_X + 9 + (i % 3) * step);
int16_t cy = (int16_t)(FACE_Y + 9 + (i / 3) * step);
oled.fillCircle(cx, cy, PIP_R, true);
}
}
/** The tally: one bar a face, as wide as its share of the throws so far. */
static void drawTally() {
uint8_t most = 1;
for (uint8_t i = 0; i < 6; i++) {
if (tally[i] > most) {
most = tally[i];
}
}
oled.fillRect(TALLY_X, TALLY_Y, TALLY_W, (int16_t)(6 * (BAR_H + BAR_GAP)), false);
for (uint8_t i = 0; i < 6; i++) {
int16_t y = (int16_t)(TALLY_Y + i * (BAR_H + BAR_GAP));
oled.number(TALLY_X, (int16_t)(y + 1), (long)(i + 1), 1, true);
int16_t bar = (int16_t)((int32_t)tally[i] * (TALLY_W - 10) / most);
oled.rect((int16_t)(TALLY_X + 8), y, (int16_t)(TALLY_W - 10), BAR_H, true);
if (bar > 2) {
oled.fillRect((int16_t)(TALLY_X + 9), (int16_t)(y + 1), (int16_t)(bar - 2),
(int16_t)(BAR_H - 2), true);
}
}
}
static void drawHeader(const char *what) {
oled.fillRect(0, 0, 128, 8, false);
oled.text(0, 0, "DICE", 1, true);
oled.text(40, 0, what, 1, true);
}
/** Held means the pin is grounded: the button's other leg is on GND. */
static bool held() { return digitalRead(BUTTON_PIN) == LOW; }
void setup() {
Serial.begin(115200);
Serial.println("Mokxi ESP32-C3: a die on a 128x64 OLED, thrown with an arcade button");
pinMode(BUTTON_PIN, INPUT_PULLUP);
if (!oled.begin()) {
Serial.println("no OLED acknowledged 0x3c");
return;
}
drawHeader("HOLD IT");
drawFace(face);
drawTally();
oled.display();
}
void loop() {
if (!held()) {
delay(8);
return;
}
// Tumbling, for as long as the button is down.
drawHeader("ROLLING");
uint32_t started = millis();
while (held()) {
face = roll();
drawFace(face);
oled.display();
delay(60);
}
lfsr ^= (uint16_t)(millis() - started);
if (lfsr == 0u) {
lfsr = 0xbeefu;
}
// Slowing to a stop: the gap grows by a twelfth each time round, so the
// tumble decays and the last few faces are separate enough to read.
uint32_t gap = 60;
while (gap < 420u) {
face = roll();
drawFace(face);
oled.display();
delay((int)gap);
gap = gap + gap / 12u + 4u;
if (held()) {
// Thrown again mid-fall: back to tumbling without counting this one.
return;
}
}
if (tally[face] < 250u) {
tally[face]++;
}
drawHeader("");
oled.number(40, 0, (long)(face + 1), 1, true);
drawTally();
oled.display();
Serial.print("rolled ");
Serial.println(face + 1);
}
Parts list
4 parts, plus the jumper wires. Every one is in the editor's parts bin.
How it is wired
5 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.
- ESP32-C3-DevKitM-1 pin 3V3; OLED display, 128x64 pin VCC
- ESP32-C3-DevKitM-1 pin 3; Arcade button pin 2
- Ground: ESP32-C3-DevKitM-1 pin GND; OLED display, 128x64 pin GND; Arcade button pin 1
- ESP32-C3-DevKitM-1 pin 4; OLED display, 128x64 pin SDA
- ESP32-C3-DevKitM-1 pin 5; OLED display, 128x64 pin SCL
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.