Built-in project · ESP32-C3-DevKitM-1

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.

Teaching? Assign it to your class in one click.

Intermediate About 20 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $18.50. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Reading a button with INPUT_PULLUP
  • Drawing dice pips from a table of bitmasks
  • Making an animation slow down gradually
  • Getting random numbers from timing and an LFSR
  • Drawing shapes and text on an I2C OLED
  • Difficulty: Intermediate
  • Time: About 20 minutes
  • Board: ESP32-C3-DevKitM-1
  • Parts: 4

How it works

An arcade button on pin 3 is wired to ground and read with INPUT_PULLUP, so holding it reads LOW. While you hold it, the die changes face every 60 ms, fast enough to look like a blur on the 128x64 OLED (SDA on pin 4, SCL on pin 5).

When you let go, the gap between faces grows a little each time. The tumble slows over about a second and a half and the die stops on a face. That face adds one to its bar in the tally on the right side of the screen.

The random number comes from a small shift register routine in the code, stepped once per frame. How long you held the button mixes into its state, so each throw differs.

Parts list

4 parts, plus the jumper wires. They retail for about $18.50; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
OLED display, 128x64 pin VCCESP32-C3-DevKitM-1 pin 3V3
Arcade button pin 2ESP32-C3-DevKitM-1 pin 3
OLED display, 128x64 pin GNDESP32-C3-DevKitM-1 pin GND
Arcade button pin 1ESP32-C3-DevKitM-1 pin GND
OLED display, 128x64 pin SDAESP32-C3-DevKitM-1 pin 4
OLED display, 128x64 pin SCLESP32-C3-DevKitM-1 pin 5

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32-C3-DevKitM-1
// 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);
}

Code walkthrough

The key functions of the sketch, and what each one does.

roll sketch.ino

This makes the next random face. It steps a 16-bit shift register called lfsr, feeding the XOR of four of its bits back in at the top. The result modulo 6 gives a number from 0 to 5. The register changes every call, so each frame shows a new value.

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);
}

drawFace sketch.ino

This draws one face from the FACES table. Each entry is a bitmask of the nine positions on a three-by-three grid. The function clears the square, draws the border, and loops over the nine positions, drawing a filled circle wherever its bit is set.

/** 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);
  }
}

loop sketch.ino

If the button is up, it waits briefly and returns. While held, it tumbles every 60 ms. On release, it mixes the hold time into lfsr and slows the tumble by growing the gap each step. Once the gap passes 420 ms, it adds one to tally for the face and prints it. Pressing again mid-fall restarts the tumble without counting.

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);
  // ... 17 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The screen stays blank and the Serial Monitor says no OLED acknowledged 0x3c This project
    Check that SDA goes to pin 4 and SCL to pin 5, and that the display has 3V3 and ground.
  • Holding the button does nothing This project
    The button's other leg must go to ground and the first to pin 3. The sketch treats LOW as pressed.
  • The die lands too quickly or too slowly This project
    The stopping speed depends on the starting gap of 60 ms and the 420 ms limit in loop. Change those numbers to tune it.
  • The OLED stays blank. OLED display
    Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address.
  • Text never shows up even though the code runs. OLED display
    Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames.
  • The button reads pressed all the time, or never changes, when wired to a pin. Arcade button
    Use the COM and NO terminals of the microswitch. Wire COM to ground, NO to the pin, and use INPUT_PULLUP. NC is the opposite and reads pressed when released.
  • One press counts as several presses in your code. Arcade button
    Microswitch contacts bounce for a few milliseconds. Ignore changes for 10 to 20 ms after the first one, or react only to the edge and wait before checking again.
  • Upload fails or the board is not found by the computer. ESP32-C3-DevKitM-1
    Pick the ESP32-C3 board in your tools and the right port. If it still fails, hold BOOT, tap RESET, then release BOOT to enter download mode and try again.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.