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; keep holding and it cycles again about every 0.9 seconds. Double-tap also cycles. Shake hard, with your finger off the glass, and the drawing fades like dust — little motes fall while the canvas dissolves.
What you will learn
That a screen is a grid of pixels, each with an (x, y) address.
How RGB color mixes red, green, and blue, and how this screen packs that into RGB565.
How to keep a drawing inside a circle (x² + y²).
How events (drag, hold, shake) become different verbs in code.
Meet the Desk Pal puck
This project uses the Waveshare ESP32-S3-Touch-LCD-1.28 — a round gadget about the size of a drink coaster.
A 240×240 color screen (think 240 dots across and 240 down).
A touch glass, so a finger is a mouse.
A tiny motion sensor (IMU) that feels shakes and flips.
Wi-Fi that only speaks 2.4 GHz. School guest networks that are 5 GHz only will be invisible to it.
A USB-C port for power and for loading the program.
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.
Note
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.
The finished gadget
Desk Pal touch doodle on Desk Pal puck (ESP32-S3).
Build it
1
Print the shell
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.
2
Flash the puck
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.
3
Draw
One fingertip to ink. Hold or double-tap to change color. Lift your finger, then shake to dust-erase. Serial color list names the six inks.
Lesson 1 — Pixels are graph paper
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.
Lesson 2 — Color is three numbers
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: neon-red, amber, lemon, lime, cyan, violet. The puck starts on cyan. Cycling color means “add one to the index, wrap around at the end.”
The panel cannot keep a full 24-bit color in every pixel. It stores RGB565: 5 bits of red, 6 of green, 5 of blue — 16 bits, 65,536 colors. Green gets the extra bit because eyes notice it more. tft.color565(r, g, b) does that squeeze. You still pick colors as 0–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.
Lesson 4 — Same sensor, different verb
A hold is “finger down, not moving much, for 1.5 seconds” → change color. Keep holding and it changes again about every 0.9 seconds. A drag is “finger moving” → paint. A shake reuses the 8-ball motion filter → dust-erase. Good programs reuse ideas.
A finger on the glass looks like motion to the IMU, so shake is ignored while you draw. Lift, then flick. If color changes while you draw, you are resting a second finger — the touch chip thinks that is a hold.
Try this
Type color lemon and color list on serial.
Add a seventh ink to the list (pick an RGB) and reflash.
Predict: if CLIP_R were 40, how much of the screen could you paint?
Talking to the board (serial)
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
Tip
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….
The complete program (reference)
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.
This sketch does not join Wi-Fi and has no password in the file. Erase uses the motion sensor on the board.
touch-doodle.ino (complete)
touch-doodle.ino
// 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;
}
}
Board and housing
Desk Pal puck (ESP32-S3)Doodle puck shellCAD of the snap-fit shell.