Desk Pal touch doodle

Finger-paint on the round LCD. Hold to cycle ink, shake the puck to dust-erase the drawing.

Desk Pal touch doodle
DifficultyBeginner
Build time40 min
Est. cost$30
Parts4
Files6
Steps3

Things you'll need

QtyPartType
1
Waveshare ESP32-S3-Touch-LCD-1.28 (Desk Pal puck)
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
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

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

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.

What you're building

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

Print the shell
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.

Flash the puck
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.

Draw

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.

Six inks, one brush

touch-doodle.ino
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}},
  {"lemon",     {255, 230, 40}},
  {"lime",      {40, 255, 100}},
  {"cyan",      {40, 200, 255}},
  {"violet",    {220, 80, 255}},
};

Lesson 3 — A circle is an inequality

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

Licensed under CC BY-SA 4.0. Check with the author before commercial use.