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.

IntermediateRuns in your browser. Free, and no account needed.

OLED dice · ESP32-C3-DevKitM-1live0.000 s 0.00x
Hold the button
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

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

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.