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

Ring Simon on an ESP32-C3

Simon on a sixteen-LED ring with a rotary encoder: turn the knob to the color you were shown and press the shaft. 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.

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

Ring Simon · 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
// Ring Simon: the memory game on a WS2812 ring, played with one knob.
//
// Wiring, board to modules:
//   ring DIN -> GPIO 5     ring VDD -> 5 V      ring VSS -> GND
//   encoder CLK -> GPIO 6  DT -> GPIO 7         SW -> GPIO 3
//   encoder +  -> 3V3      GND -> GND
//
// Simon is normally four buttons. Here the whole board is one knob and one
// ring, which is a better fit for the shape of the game: the ring is a dial of
// sixteen colours, the machine picks a few of them and shows them to you one at
// a time, and you turn the knob back to each one in turn and press it.
//
// # The dial
//
// Sixteen LEDs, sixteen slots, one colour each: a sixth of the hue wheel per
// slot, so neighbours are told apart by eye rather than by counting. The knob
// selects a slot (one click, one LED), and the selected one is the only one
// lit while you are choosing, at full brightness against fifteen dark ones.
//
// Turning past the end wraps round, because a ring has no end. `& 15` does it,
// which is why the ring is sixteen and not twelve.
//
// # Reading the knob
//
// mokxi_rotary.h counts clicks off the quadrature pair with a state table
// rather than watching for an edge on CLK, because the contacts chatter and one
// bounce is a second edge inside the same click. It has to be sampled faster
// than the shaft moves, so `update()` is called in every wait loop here, not
// once a frame: a quarter step is eight milliseconds and a missed one is a
// click counted backwards.
//
// Every one of those loops used to spin flat out with no wait at all, which
// on a real board would just burn a core for nothing, but on Mokxi means the
// interpreter never gets to jump the idle stretches: `delay()` of 50 us or
// more parks the core until its timer fires instead of retiring an
// instruction a cycle, which is what lets the engine run faster than it can
// interpret raw cycles. So every loop that only watches the knob calls
// `delay(1)` between samples; a millisecond is over a hundred times
// finer than the eight-millisecond quarter step above, plenty of margin
// against the state table's own bounce window, and slow enough next to it
// that the core spends its time parked rather than spinning. Verified in the
// browser at both a normal turning speed and the fastest the pointer can
// throw the shaft (the encoder model itself paces quarter steps eight
// milliseconds apart no matter how fast the wheel is spun, so 1 ms polling
// never has to keep up with anything faster than that): every detent lands
// once, in the right direction, both ways.
//
// # The sequence
//
// A sixteen-bit shift register with taps at 16, 14, 13 and 11 (the usual
// maximal-length LFSR) is the source of the slots, seeded from how long the
// player took to press the first time. So the sequence is different every game
// and the same one is never two games running.

#include "mokxi_rotary.h"
#include "mokxi_ws2812.h"

const uint8_t DIN_PIN = 5;
const uint8_t CLK_PIN = 6;
const uint8_t DT_PIN = 7;
const uint8_t SW_PIN = 3;

const uint16_t LEDS = 16;
const uint8_t SLOTS = 16;
const int MAX_LEN = 24;

// Three bytes an LED, green first: the order the wire wants them in.
uint8_t pixels[LEDS * 3];
Ws2812 ring(DIN_PIN, pixels, LEDS);
Rotary knob(CLK_PIN, DT_PIN, SW_PIN);

uint8_t sequence[MAX_LEN];
int length = 1;

// Full scale on sixteen LEDs is a floodlight; these are the two levels the
// game uses, one for the lamp it is showing and one for the dim ring behind.
const uint8_t BRIGHT = 90;
// Bright enough to read as a coloured dial before the first press. Ten was
// technically lit but looked black on an ordinary display.
const uint8_t DIM = 36;

uint16_t lfsr = 0xace1u;

static uint8_t nextSlot() {
  // 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 % SLOTS);
}

/** The colour of slot `i`: a sixteenth of the hue wheel each. */
static void slotColor(uint8_t i, uint8_t level, uint8_t *r, uint8_t *g, uint8_t *b) {
  ws2812_wheel((uint16_t)((uint32_t)i * 1536u / SLOTS), r, g, b);
  *r = ws2812_scale(*r, level);
  *g = ws2812_scale(*g, level);
  *b = ws2812_scale(*b, level);
}

/** Every LED dark but `lit`, which shows its own colour at `level`. */
static void showOne(int lit, uint8_t level) {
  uint8_t r;
  uint8_t g;
  uint8_t b;
  ring.clear();
  if (lit >= 0) {
    slotColor((uint8_t)lit, level, &r, &g, &b);
    ring.setPixel((uint16_t)lit, r, g, b);
  }
  ring.show();
}

/** The whole ring in its own colours, dim: the board at rest. */
static void showDial(uint8_t level) {
  uint8_t r;
  uint8_t g;
  uint8_t b;
  for (uint8_t i = 0; i < LEDS; i++) {
    slotColor(i, level, &r, &g, &b);
    ring.setPixel(i, r, g, b);
  }
  ring.show();
}

/** Wait, sampling the knob the whole time so no click is missed. */
static void waitMs(uint32_t ms) {
  uint32_t until = millis() + ms;
  while ((int32_t)(millis() - until) < 0) {
    knob.update();
    delay(1);
  }
}

/** Play the sequence back at the player, one lamp at a time. */
static void playSequence() {
  for (int i = 0; i < length; i++) {
    showOne(sequence[i], BRIGHT);
    waitMs(420);
    showOne(-1, 0);
    waitMs(160);
  }
  showDial(DIM);
}

/** Spin the ring once, as a flourish. */
static void spin(uint8_t level, int laps, uint32_t step) {
  for (int lap = 0; lap < laps; lap++) {
    for (uint8_t i = 0; i < LEDS; i++) {
      showOne(i, level);
      waitMs(step);
    }
  }
  showOne(-1, 0);
}

/**
 * Let the player choose a slot and press it. Returns the slot.
 *
 * The knob is the only input, so the selected slot is drawn as it moves and
 * nothing is committed until the shaft goes down.
 */
static uint8_t chooseSlot(uint8_t from) {
  int8_t at = (int8_t)from;
  showOne(at, BRIGHT);
  knob.update();
  (void)knob.take();
  (void)knob.pressed();
  for (;;) {
    knob.update();
    int16_t clicks = knob.take();
    if (clicks != 0) {
      // A ring has no end, so turning past sixteen comes back to nought.
      at = (int8_t)(((int)at + clicks) & (SLOTS - 1));
      showOne(at, BRIGHT);
    }
    if (knob.pressed()) {
      return (uint8_t)at;
    }
    delay(1);
  }
}

void setup() {
  Serial.begin(115200);
  Serial.println("Mokxi ESP32-C3: Simon on a WS2812 ring, played with a knob");
  ring.begin();
  ring.clear();
  ring.show();
  knob.begin();

  // The seed: how long the player takes to press the first time. Nothing here
  // reads a clock that is not the player's own hand, so a run is reproducible
  // when the hand is.
  showDial(DIM);
  Serial.println("Press the knob to start");
  while (!knob.pressed()) {
    knob.update();
    delay(1);
  }
  lfsr ^= (uint16_t)millis();
  if (lfsr == 0) {
    lfsr = 0xace1u;
  }
  sequence[0] = nextSlot();
  length = 1;
  spin(DIM, 1, 25);
}

void loop() {
  waitMs(500);
  playSequence();

  uint8_t at = sequence[0];
  for (int i = 0; i < length; i++) {
    at = chooseSlot(at);
    if (at != sequence[i]) {
      Serial.print("wrong at step ");
      Serial.print(i + 1);
      Serial.print(" of ");
      Serial.println(length);
      // The slot they should have chosen, three times, then start again.
      for (int f = 0; f < 3; f++) {
        showOne(sequence[i], BRIGHT);
        waitMs(180);
        showOne(-1, 0);
        waitMs(180);
      }
      length = 1;
      sequence[0] = nextSlot();
      showDial(DIM);
      return;
    }
    // Right: acknowledge it and move on.
    showOne(at, BRIGHT);
    waitMs(120);
    showDial(DIM);
  }

  Serial.print("round ");
  Serial.print(length);
  Serial.println(" done");
  spin(BRIGHT, 1, 18);
  if (length < MAX_LEN) {
    sequence[length] = nextSlot();
    length++;
  }
  showDial(DIM);
}

Parts list

4 parts, plus the jumper wires. Every one is in the editor's parts bin.

How it is wired

8 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.

  • Ground: ESP32-C3-DevKitM-1 pin GND; Rotary encoder, KY-040 pin GND
  • ESP32-C3-DevKitM-1 pin 3V3; Rotary encoder, KY-040 pin VCC
  • ESP32-C3-DevKitM-1 pin 3; Rotary encoder, KY-040 pin SW
  • Ground: ESP32-C3-DevKitM-1 pin GND; Addressable RGB LEDs pin VSS
  • ESP32-C3-DevKitM-1 pin 5; Addressable RGB LEDs pin DIN
  • ESP32-C3-DevKitM-1 pin 6; Rotary encoder, KY-040 pin CLK
  • ESP32-C3-DevKitM-1 pin 7; Rotary encoder, KY-040 pin DT
  • ESP32-C3-DevKitM-1 pin 5V; Addressable RGB LEDs pin VDD

Change it and keep it

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