What you're building
A round finger-paint / Etch A Sketch. Drag to draw. Hold still about 1.5 seconds to cycle ink colors. Double-tap also cycles. Shake hard and the drawing fades like dust — little motes fall while the canvas dissolves.
Finger-paint on the round LCD. Hold to cycle ink, shake the puck to dust-erase the drawing.

| Qty | Part | Type |
|---|---|---|
| 1 |
Waveshare ESP32-S3-Touch-LCD-1.28 (Desk Pal puck)
Round 240×240 GC9A01, CST816S touch, QMI8658 IMU, USB-C. Amazon B0CM68M8LR.
|
electronics |
| 1 |
Doodle puck shell
Plain round snap shell. Light PLA shows the screen best.
|
printed |
| 1 |
USB-C cable
Data-capable cable for flash and power.
|
electronics |
| 1 |
PLA or PETG
About 15–25 g. PETG if the puck lives in a bag.
|
consumable |
| 1 |
PlatformIO or Arduino IDE
Flash over USB-C. Serial 115200.
|
Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.
A round finger-paint / Etch A Sketch. Drag to draw. Hold still about 1.5 seconds to cycle ink colors. Double-tap also cycles. Shake hard and the drawing fades like dust — little motes fall while the canvas dissolves.
x² + y²).This project uses the Waveshare ESP32-S3-Touch-LCD-1.28 — a round gadget about the size of a drink coaster.
The program you load is a .ino file — that is Arduino-style C++. You do not need to understand every line. This guide teaches the ideas, then shows the few lines that make each idea real.
Word to know — firmware. That is just the program living on the chip. Flashing firmware is like installing an app, except the chip can only run one app at a time.
Light PLA, 0.2 mm. Snap the puck USB-down. A thin bezel looks better than a thick hood — you want the glass easy to reach.
Flash means copy the program onto the chip. Easiest path: use the attached touch-doodle.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32S3 Dev Module, OPI PSRAM, 16 MB flash, USB CDC On Boot — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. The TFT setup must include USE_HSPI_PORT or the round screen stays black.
One fingertip to ink. Hold or double-tap to change color. Shake to erase. Serial color list names the six inks.
The screen is 240 by 240 pixels (picture elements). The center is about (120, 120). Right is +x, down is +y (screens often grow downward — opposite of the y-axis in math class).
When you drag, the touch chip reports a finger position. The program stamps a small disc of the current ink there — a brush of radius 3 pixels.
RGB means red, green, blue. Each goes from 0 (off) to 255 (full). (255, 40, 80) is neon red. (40, 200, 255) is cyan. The six inks are just six triples in a list. Cycling color means “add one to the index, wrap around at the end.”
static const int CLIP_R = 118; // keep ink inside this radius
static const int BRUSH = 3; // stamp size
static const Ink INKS[] = {
{"neon-red", {255, 40, 80}},
{"amber", {255, 160, 24}},
{"cyan", {40, 200, 255}},
{"violet", {220, 80, 255}},
};
A point (x, y) is inside a circle of radius R at the center if (x−120)² + (y−120)² ≤ R². The firmware uses that test so you never scribble into the unused corners of the square sprite. That is the same circle equation from geometry, just used as a clip.
A hold is “finger down, not moving much, for 1.5 seconds” → change color. A drag is “finger moving” → paint. A shake reuses the 8-ball motion filter → erase. Good programs reuse ideas. If color changes while you draw, you are resting a second finger — the touch chip thinks that is a hold.
color lemon and color list on serial.CLIP_R were 40, how much of the screen could you paint?Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).
Type a word, press Enter, and the board answers. Try help first. Then try status.
Commands for this project: clear · color [n|name|list] · status · help
If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….
Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.
Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.
// Finger doodle / etch-a-sketch for Waveshare ESP32-S3-Touch-LCD-1.28
// 1.28" round GC9A01, CST816S touch, QMI8658 IMU (Amazon B0CM68M8LR).
//
// Drag to draw (clipped to the circle). Hold ~1.5s to cycle ink (keep holding
// to keep cycling). Double-tap also cycles. Shake hard to fade/dust-erase.
//
// Serial (115200): clear | color [n|name|list] | status | help
//
// Arduino IDE: ESP32S3 Dev Module, OPI PSRAM, 16MB flash, USB CDC On Boot.
// TFT_eSPI: apply board-sketches/touch-doodle/firmware/TFT_eSPI_Setup.h
// Sim: python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/touch-doodle
#include <Arduino.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <math.h>
#include "../../../shared/s3_puck_pins.h"
static const int LCD = S3_LCD_SIZE;
static const int CX = 120;
static const int CY = 120;
static const int CLIP_R = 118;
static const int CLIP_R2 = CLIP_R * CLIP_R;
static const int BRUSH = 3;
static const int BRUSH2 = BRUSH * BRUSH;
static const bool TOUCH_FLIP_X = false;
static const bool TOUCH_FLIP_Y = false;
// Etch-a-sketch: a wrist flick erases. Desk fidget / drawing must not.
static const float SHAKE_DECAY = 0.84f;
static const float SHAKE_ENTER = 2.35f;
static const float SHAKE_EXIT = 1.00f;
static const float SHAKE_ACC_DEAD = 0.28f; // | |a|-1g | below this is ignored
static const float SHAKE_GYRO_DEAD = 130.0f; // dps below this is ignored
static const uint32_t SHAKE_ARM_MS = 90;
static const int MOVE_DRAW_PX = 10;
static const int HOLD_STILL_PX = 36;
static const uint32_t LONG_PRESS_MS = 1500;
static const uint32_t COLOR_REPEAT_MS = 900;
static const uint8_t BG[3] = {18, 16, 22};
struct Ink {
const char *name;
uint8_t rgb[3];
};
static const Ink INKS[] = {
{"neon-red", {255, 40, 80}},
{"amber", {255, 160, 24}},
{"lemon", {255, 230, 40}},
{"lime", {40, 255, 100}},
{"cyan", {40, 200, 255}},
{"violet", {220, 80, 255}},
};
static const int INK_COUNT = sizeof(INKS) / sizeof(INKS[0]);
struct Vec3 {
float x, y, z;
};
struct Mote {
float x, y, vx, vy, life;
uint8_t rgb[3];
};
static const int MOTE_MAX = 28;
TFT_eSPI tft;
TFT_eSprite spr(&tft);
static uint16_t colBg = 0;
static uint16_t colInk = 0;
static int inkIndex = 4; // cyan
static uint32_t strokes = 0;
static uint8_t imuAddr = 0;
static bool imuOk = false;
static Vec3 accRaw = {0, 0, 1};
static Vec3 gyroDps = {0, 0, 0};
static float shakeEnergy = 0;
static bool shaking = false;
static uint32_t shakeArmAt = 0;
static float eraseTail = 0;
static bool touchOk = false;
static volatile bool touchIrq = false;
static bool fingerDown = false;
static uint32_t fingerDownAt = 0;
static int16_t touchX0 = 0, touchY0 = 0, touchX = 0, touchY = 0;
static int16_t lastDrawX = 0, lastDrawY = 0;
static bool drewThisStroke = false;
static bool longPressFired = false;
static uint32_t nextColorAt = 0;
static uint32_t lastTapAt = 0;
static Mote motes[MOTE_MAX];
static int moteCount = 0;
static uint32_t nextStatsAt = 0;
static bool canvasDirty = true;
// ---------------------------------------------------------------- helpers
static float clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
static float lerpf(float a, float b, float t) { return a + (b - a) * t; }
static int32_t since(uint32_t now, uint32_t then) { return (int32_t)(now - then); }
static bool after(uint32_t now, uint32_t when) { return since(now, when) >= 0; }
static float vecLen(const Vec3 &v) { return sqrtf(v.x * v.x + v.y * v.y + v.z * v.z); }
static uint16_t packRgb(uint8_t r, uint8_t g, uint8_t b) {
return tft.color565(r, g, b);
}
static bool inCircle(int x, int y) {
const int dx = x - CX;
const int dy = y - CY;
return dx * dx + dy * dy <= CLIP_R2;
}
static void plot(int x, int y, uint16_t c) {
if ((unsigned)x >= (unsigned)LCD || (unsigned)y >= (unsigned)LCD) return;
if (!inCircle(x, y)) return;
spr.drawPixel(x, y, c);
}
static void stamp(int x, int y, uint16_t c, int r) {
const int r2 = r * r;
for (int dy = -r; dy <= r; dy++) {
for (int dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy <= r2) plot(x + dx, y + dy, c);
}
}
}
static void drawStroke(int x0, int y0, int x1, int y1, uint16_t c) {
int steps = max(abs(x1 - x0), abs(y1 - y0));
if (steps < 1) steps = 1;
for (int i = 0; i <= steps; i++) {
const int x = x0 + (x1 - x0) * i / steps;
const int y = y0 + (y1 - y0) * i / steps;
stamp(x, y, c, BRUSH);
}
canvasDirty = true;
}
static void fillCanvas() {
spr.fillSprite(TFT_BLACK);
spr.fillCircle(CX, CY, CLIP_R, colBg);
canvasDirty = true;
}
static void setInk(int i) {
i = ((i % INK_COUNT) + INK_COUNT) % INK_COUNT;
inkIndex = i;
colInk = packRgb(INKS[i].rgb[0], INKS[i].rgb[1], INKS[i].rgb[2]);
canvasDirty = true;
Serial.printf("color=%s (%d)\n", INKS[inkIndex].name, inkIndex);
}
static void cycleInk() { setInk(inkIndex + 1); }
static void clearInstant() {
fillCanvas();
moteCount = 0;
eraseTail = 0;
Serial.println("cleared");
}
static void unpack565(uint16_t c, int &r, int &g, int &b) {
r = (c >> 8) & 0xF8;
r |= r >> 5;
g = (c >> 3) & 0xFC;
g |= g >> 6;
b = (c << 3) & 0xF8;
b |= b >> 5;
}
static uint16_t fadeTowardBg(uint16_t c, float t) {
int r, g, b;
unpack565(c, r, g, b);
r = (int)lerpf((float)r, BG[0], t);
g = (int)lerpf((float)g, BG[1], t);
b = (int)lerpf((float)b, BG[2], t);
return packRgb((uint8_t)r, (uint8_t)g, (uint8_t)b);
}
static void spawnMote(float x, float y, float energy) {
if (moteCount >= MOTE_MAX) {
motes[random(0, MOTE_MAX)] = motes[--moteCount];
}
Mote &m = motes[moteCount++];
const float ang = random(0, 6283) / 1000.0f;
const float spd = 18.0f + energy * 38.0f + random(0, 40);
m.x = x;
m.y = y;
m.vx = cosf(ang) * spd;
m.vy = sinf(ang) * spd;
m.life = 0.45f + energy * 0.25f;
if (random(0, 100) < 45) {
m.rgb[0] = INKS[inkIndex].rgb[0];
m.rgb[1] = INKS[inkIndex].rgb[1];
m.rgb[2] = INKS[inkIndex].rgb[2];
} else {
m.rgb[0] = 210;
m.rgb[1] = 205;
m.rgb[2] = 220;
}
}
static void punchDust(float energy) {
const int punches = (int)(90 + energy * 140);
for (int i = 0; i < punches; i++) {
const float ang = random(0, 6283) / 1000.0f;
const float rr = sqrtf(random(0, 10000) / 10000.0f) * (CLIP_R - 2);
const int x = CX + (int)(cosf(ang) * rr);
const int y = CY + (int)(sinf(ang) * rr);
stamp(x, y, colBg, 1 + (energy > 1.6f ? 1 : 0));
}
const int fades = (int)(180 + energy * 220);
for (int i = 0; i < fades; i++) {
const float ang = random(0, 6283) / 1000.0f;
const float rr = sqrtf(random(0, 10000) / 10000.0f) * (CLIP_R - 2);
const int x = CX + (int)(cosf(ang) * rr);
const int y = CY + (int)(sinf(ang) * rr);
const uint16_t p = spr.readPixel(x, y);
if (p != colBg && p != 0) spr.drawPixel(x, y, fadeTowardBg(p, 0.42f));
}
canvasDirty = true;
}
static void updateMotes(float dt) {
for (int i = moteCount - 1; i >= 0; i--) {
Mote &m = motes[i];
m.x += m.vx * dt;
m.y += m.vy * dt;
m.vx *= 0.92f;
m.vy *= 0.92f;
m.life -= dt;
if (m.life <= 0 || !inCircle((int)m.x, (int)m.y)) {
motes[i] = motes[--moteCount];
}
}
}
// ---------------------------------------------------------------------- IMU
static bool imuWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(imuAddr);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
static bool imuReadBytes(uint8_t reg, uint8_t *buf, uint8_t n) {
Wire.beginTransmission(imuAddr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)imuAddr, (int)n) != n) return false;
for (uint8_t i = 0; i < n; i++) buf[i] = Wire.read();
return true;
}
static bool imuProbe(uint8_t addr) {
imuAddr = addr;
uint8_t id = 0;
if (!imuReadBytes(S3_QMI_WHO_AM_I, &id, 1)) return false;
return id == S3_QMI_ID;
}
static bool imuBegin() {
if (!imuProbe(0x6B) && !imuProbe(0x6A)) {
imuAddr = 0;
return false;
}
imuWrite(S3_QMI_CTRL1, 0x60);
imuWrite(S3_QMI_CTRL2, 0x23);
imuWrite(S3_QMI_CTRL3, 0x43);
imuWrite(S3_QMI_CTRL7, 0x03);
delay(20);
return true;
}
static bool imuRead() {
uint8_t buf[12];
if (!imuReadBytes(S3_QMI_AX_L, buf, 12)) return false;
auto s16 = [](uint8_t lo, uint8_t hi) -> int16_t {
return (int16_t)((uint16_t)lo | ((uint16_t)hi << 8));
};
accRaw.x = s16(buf[0], buf[1]) / S3_ACCEL_LSB_PER_G;
accRaw.y = s16(buf[2], buf[3]) / S3_ACCEL_LSB_PER_G;
accRaw.z = s16(buf[4], buf[5]) / S3_ACCEL_LSB_PER_G;
gyroDps.x = s16(buf[6], buf[7]) / S3_GYRO_LSB_PER_DPS;
gyroDps.y = s16(buf[8], buf[9]) / S3_GYRO_LSB_PER_DPS;
gyroDps.z = s16(buf[10], buf[11]) / S3_GYRO_LSB_PER_DPS;
return true;
}
static void printHelp() {
Serial.println("clear | color [n|name|list] | status | help");
Serial.println("hold ~1.5s (or double-tap) to cycle ink; shake to erase");
Serial.print("colors:");
for (int i = 0; i < INK_COUNT; i++) Serial.printf(" %s", INKS[i].name);
Serial.println();
}
static void updateMotion(uint32_t now) {
if (!imuOk || !imuRead()) return;
// Pressing the glass to draw looks like motion. Ignore it.
if (fingerDown) {
shakeEnergy *= 0.65f;
shaking = false;
shakeArmAt = 0;
return;
}
const float mag = vecLen(accRaw);
const float dev = fabsf(mag - 1.0f);
const float gyro = vecLen(gyroDps);
const float accTerm = dev > SHAKE_ACC_DEAD ? (dev - SHAKE_ACC_DEAD) * 0.48f : 0.0f;
const float gyroTerm =
gyro > SHAKE_GYRO_DEAD ? (gyro - SHAKE_GYRO_DEAD) / 1300.0f : 0.0f;
shakeEnergy = shakeEnergy * SHAKE_DECAY + accTerm + gyroTerm;
if (shakeEnergy > SHAKE_ENTER) {
if (!shakeArmAt) shakeArmAt = now;
if (!shaking && since(now, shakeArmAt) >= (int32_t)SHAKE_ARM_MS) shaking = true;
} else {
shakeArmAt = 0;
}
if (shaking && shakeEnergy < SHAKE_EXIT) shaking = false;
}
// ------------------------------------------------------------------- touch
static void touchReset() {
pinMode(S3_TP_RST_PIN, OUTPUT);
pinMode(S3_TP_INT_PIN, INPUT_PULLUP);
digitalWrite(S3_TP_RST_PIN, LOW);
delay(12);
digitalWrite(S3_TP_RST_PIN, HIGH);
delay(80);
}
static bool touchWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(S3_CST816S_ADDR);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
static bool touchBegin() {
Wire.beginTransmission(S3_CST816S_ADDR);
Wire.write(0xA7);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)S3_CST816S_ADDR, 1) != 1) return false;
Wire.read();
touchWrite(0xFE, 0x01);
touchWrite(0xFA, 0x61); // IRQ + EnDClick so 0x0B / 0x0C gestures report
touchWrite(0xEB, 15); // long-press ~1.5s (100 ms units) on CST816S
return true;
}
static bool readTouch(uint8_t *gesture, uint8_t *fingers, int16_t *x, int16_t *y) {
Wire.beginTransmission(S3_CST816S_ADDR);
Wire.write(0x01);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)S3_CST816S_ADDR, 6) < 6) return false;
*gesture = Wire.read();
*fingers = Wire.read() & 0x0F;
const uint8_t xh = Wire.read();
const uint8_t xl = Wire.read();
const uint8_t yh = Wire.read();
const uint8_t yl = Wire.read();
int16_t px = ((xh & 0x0F) << 8) | xl;
int16_t py = ((yh & 0x0F) << 8) | yl;
if (TOUCH_FLIP_X) px = LCD - 1 - px;
if (TOUCH_FLIP_Y) py = LCD - 1 - py;
*x = px;
*y = py;
return true;
}
static void IRAM_ATTR onTouchIrq() { touchIrq = true; }
static void onDoubleTap() { cycleInk(); }
static void onHoldColor() { cycleInk(); }
static void holdMaybeCycle(uint32_t now) {
if (drewThisStroke) return;
if (abs(touchX - touchX0) >= HOLD_STILL_PX || abs(touchY - touchY0) >= HOLD_STILL_PX) {
return;
}
if (!longPressFired && since(now, fingerDownAt) > (int32_t)LONG_PRESS_MS) {
longPressFired = true;
nextColorAt = now + COLOR_REPEAT_MS;
onHoldColor();
} else if (longPressFired && nextColorAt && after(now, nextColorAt)) {
nextColorAt = now + COLOR_REPEAT_MS;
onHoldColor();
}
}
static void handleTouch(uint32_t now) {
if (!touchOk) return;
const bool irq = touchIrq || digitalRead(S3_TP_INT_PIN) == LOW;
touchIrq = false;
if (!irq && !fingerDown) return;
if (irq) {
uint8_t gesture = 0;
uint8_t fingers = 0;
int16_t x = 0, y = 0;
const bool ok = readTouch(&gesture, &fingers, &x, &y);
const bool down = ok && fingers > 0;
// CST816S reports LONG_PRESS (0x0C) / DOUBLE_CLICK (0x0B) then often
// drops the finger count, so handle the gesture even when fingers == 0.
if (ok && (gesture == 0x0C || gesture == 0x0B)) {
onHoldColor();
longPressFired = true;
nextColorAt = now + COLOR_REPEAT_MS;
if (gesture == 0x0B) lastTapAt = 0;
}
if (down) {
touchX = x;
touchY = y;
if (!fingerDown) {
fingerDown = true;
fingerDownAt = now;
touchX0 = x;
touchY0 = y;
lastDrawX = x;
lastDrawY = y;
drewThisStroke = false;
if (gesture != 0x0C && gesture != 0x0B) {
longPressFired = false;
nextColorAt = 0;
}
} else {
const int16_t from0x = abs(x - touchX0);
const int16_t from0y = abs(y - touchY0);
const bool moved = from0x >= MOVE_DRAW_PX || from0y >= MOVE_DRAW_PX;
const int16_t dx = x - lastDrawX;
const int16_t dy = y - lastDrawY;
if (moved && !longPressFired && abs(dx) + abs(dy) >= 2) {
drawStroke(lastDrawX, lastDrawY, x, y, colInk);
lastDrawX = x;
lastDrawY = y;
if (!drewThisStroke) strokes++;
drewThisStroke = true;
}
}
holdMaybeCycle(now);
return;
}
// Fresh IRQ with fingers == 0 is a real lift. Polling between pulses
// often returns 0 fingers while the finger is still down — ignore that.
if (fingerDown && ok && fingers == 0) {
fingerDown = false;
nextColorAt = 0;
if (longPressFired) return;
if (drewThisStroke) return;
if (since(now, fingerDownAt) < 600) {
if (since(now, lastTapAt) < 350) {
lastTapAt = 0;
onDoubleTap();
} else {
lastTapAt = now;
}
}
return;
}
}
if (fingerDown) holdMaybeCycle(now);
}
// ------------------------------------------------------------------ serial
static int inkByName(const String &s) {
for (int i = 0; i < INK_COUNT; i++) {
if (s.equalsIgnoreCase(INKS[i].name)) return i;
}
return -1;
}
static void printStatus() {
Serial.printf("color=%s(%d) shaking=%d energy=%.2f imu=%d touch=%d strokes=%lu\n",
INKS[inkIndex].name, inkIndex, shaking, shakeEnergy, imuOk, touchOk,
(unsigned long)strokes);
}
static void handleSerial() {
static String line;
while (Serial.available()) {
const char c = (char)Serial.read();
if (c == '\r') continue;
if (c != '\n') {
if (line.length() < 80) line += c;
continue;
}
line.trim();
if (line.length() == 0) continue;
int sp = line.indexOf(' ');
String cmd = sp < 0 ? line : line.substring(0, sp);
String arg = sp < 0 ? "" : line.substring(sp + 1);
arg.trim();
cmd.toLowerCase();
if (cmd == "clear") {
clearInstant();
} else if (cmd == "color") {
if (arg.length() == 0) {
cycleInk();
} else if (arg == "list") {
for (int i = 0; i < INK_COUNT; i++) {
Serial.printf("%d %s\n", i, INKS[i].name);
}
} else {
int idx = inkByName(arg);
if (idx < 0) idx = arg.toInt();
if (idx < 0 || idx >= INK_COUNT) Serial.println("unknown color");
else setInk(idx);
}
} else if (cmd == "status") {
printStatus();
} else if (cmd == "help") {
printHelp();
} else {
Serial.println("? (try help)");
}
line = "";
}
}
static void drawOverlay() {
const uint16_t rim = colInk;
tft.drawCircle(CX, CY, CLIP_R, rim);
tft.drawCircle(CX, CY, CLIP_R - 1, rim);
for (int i = 0; i < moteCount; i++) {
const Mote &m = motes[i];
const float a = clampf(m.life / 0.45f, 0, 1);
const uint16_t c = packRgb((uint8_t)(m.rgb[0] * a), (uint8_t)(m.rgb[1] * a),
(uint8_t)(m.rgb[2] * a));
tft.fillCircle((int)m.x, (int)m.y, m.life > 0.25f ? 2 : 1, c);
}
}
void setup() {
Serial.begin(115200);
pinMode(S3_LCD_BL_PIN, OUTPUT);
digitalWrite(S3_LCD_BL_PIN, HIGH);
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
spr.setColorDepth(16);
if (spr.createSprite(LCD, LCD) == nullptr) {
Serial.println("sprite alloc failed");
while (true) delay(1000);
}
colBg = packRgb(BG[0], BG[1], BG[2]);
setInk(inkIndex);
fillCanvas();
spr.pushSprite(0, 0);
drawOverlay();
Wire.begin(S3_I2C_SDA_PIN, S3_I2C_SCL_PIN);
Wire.setClock(400000);
touchReset();
touchOk = touchBegin();
if (touchOk) attachInterrupt(digitalPinToInterrupt(S3_TP_INT_PIN), onTouchIrq, FALLING);
imuOk = imuBegin();
Serial.println(imuOk ? "QMI8658 ready" : "QMI8658 not found — shake off");
Serial.println(touchOk ? "CST816S ready" : "CST816S not found — touch off");
nextStatsAt = millis() + 5000;
Serial.println("touch doodle ready (type 'help')");
}
void loop() {
static uint32_t lastMs = millis();
const uint32_t now = millis();
const float dt = clampf((now - lastMs) / 1000.0f, 0.001f, 0.05f);
lastMs = now;
handleSerial();
handleTouch(now);
updateMotion(now);
if (shaking) {
eraseTail = 0.55f;
punchDust(shakeEnergy);
if (random(0, 100) < 70) {
spawnMote((float)(CX + random(-40, 41)), (float)(CY + random(-40, 41)), shakeEnergy);
}
} else if (eraseTail > 0) {
eraseTail -= dt;
punchDust(0.45f);
}
updateMotes(dt);
if (canvasDirty || moteCount > 0 || eraseTail > 0) {
spr.pushSprite(0, 0);
drawOverlay();
canvasDirty = false;
}
if (after(now, nextStatsAt)) {
printStatus();
nextStatsAt = now + 5000;
}
}
Licensed under CC BY-SA 4.0. Check with the author before commercial use.
Flash firmware