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.
Teaching? Assign it to your class in one click.
Advanced About 25 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $15. On Mokxi they're free.
What you'll learn
- Reading a rotary encoder and its push switch
- Driving a WS2812 ring with color and brightness levels
- Generating a pseudo-random sequence with a shift register
- Wrapping around a ring with a bit mask
- Polling inputs while waiting, so no click is missed
- Difficulty: Advanced
- Time: About 25 minutes
- Board: ESP32-C3-DevKitM-1
- Parts: 4
How it works
The sixteen LEDs on the ring each get their own color from the hue wheel, shown dim at rest. Press the knob to start. The first press also seeds a shift register that picks the sequence, so every game is different.
The ESP32-C3 lights one color at a time to show you the sequence. Then you turn the encoder until the color you remember is lit, and press the shaft to choose it. Turning past the end wraps around the ring.
A right answer flashes and moves on. After the full sequence, the ring spins and one more color is added. A wrong answer flashes the right color three times and restarts with a new sequence.
Parts list
4 parts, plus the jumper wires. They retail for about $15; on Mokxi they're free.
- 1 × ESP32-C3-DevKitM-1In Handheld Console Kit, Desk Buddy Kit.
- 1 × Half breadboard
- 1 × Addressable RGB LEDsIn Claw Machine Kit, Halloween Prop Kit, Christmas Lights Kit.
- 1 × Rotary encoder, KY-040
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 pin | Connects to |
|---|---|
| Rotary encoder, KY-040 pin GND | ESP32-C3-DevKitM-1 pin GND |
| Rotary encoder, KY-040 pin VCC | ESP32-C3-DevKitM-1 pin 3V3 |
| Rotary encoder, KY-040 pin SW | ESP32-C3-DevKitM-1 pin 3 |
| Addressable RGB LEDs pin VSS | ESP32-C3-DevKitM-1 pin GND |
| Addressable RGB LEDs pin DIN | ESP32-C3-DevKitM-1 pin 5 |
| Rotary encoder, KY-040 pin CLK | ESP32-C3-DevKitM-1 pin 6 |
| Rotary encoder, KY-040 pin DT | ESP32-C3-DevKitM-1 pin 7 |
| Addressable RGB LEDs pin VDD | ESP32-C3-DevKitM-1 pin 5V |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// 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);
}
Code walkthrough
The key functions of the sketch, and what each one does.
nextSlot sketch.ino
Steps a 16-bit shift register one place and returns a slot from 0 to 15. The register is the maximal-length kind, so it does not repeat for a long time. It is seeded from how long you took to press first.
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);
}waitMs sketch.ino
A delay that keeps listening. Until the time is up, it calls knob.update() and delay(1) over and over, so a turn of the knob during a pause is still counted.
/** 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);
}
}playSequence sketch.ino
Shows the sequence back to you. For each step it lights that slot at BRIGHT for 420 ms, then goes dark for 160 ms. At the end it shows the dim dial again.
/** 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);
}chooseSlot sketch.ino
Starts at the last slot you picked and lights only the selected LED. Each click moves the selection, wrapping at 16. It returns the selected slot when the shaft is pressed.
/**
* 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);
}
}Troubleshooting
The usual mistakes with this circuit, and the fix for each.
- The knob moves the light the wrong way. This project
Swap the CLK and DT wires, on pins 6 and 7, or swap the two pin numbers in the sketch. - Pressing the shaft does nothing. This project
The encoder's SW pin must go to pin 3. - The ring stays dark. This project
Check that DIN goes to pin 5, VDD to 5 V and VSS to ground. - The first LED flickers, shows wrong colors, or the whole strip glitches. Addressable RGB LEDs
Join the grounds of the strip and the controller. Add a 300 to 500 ohm resistor in series with the data line. Put a 1000 uF capacitor across the strip's 5 V and GND. - The strip works at low brightness but dims, turns yellow, or resets when many LEDs go white. Addressable RGB LEDs
Each LED can draw about 60 mA at full white. Use a 5 V supply sized for the count, feed power in at several points, and lower the brightness in code. - Turning the knob counts twice, skips, or goes the wrong way. The contacts bounce and polling is too slow. Rotary encoder
Use interrupts or a proper encoder library on pins CLK and DT. Use INPUT_PULLUP, since the contacts are open and shut to ground. Swap CLK and DT to flip direction. - The KY-040 pins read wrong or noisy. The board has pull-ups to its + pin, so + must be powered, and its switch needs debounce. Rotary encoder
Connect + to 5 V or 3.3 V to match your board's logic level and GND to ground. Debounce SW for about 20 ms. One click often equals four state changes. - 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.