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