Desk Pal fortune 8-ball

Shake the round puck and a blue triangle swirls up a fortune. Tap to ask, tap again to put the 8 back.

Desk Pal fortune 8-ball
DifficultyBeginner
Build time45 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
8-ball shell
8-ball shell Glossy-looking round snap shell. Print in black PLA, 0.16–0.2 mm.
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 magic 8-ball you can actually shake. Idle is a glossy black circle with an 8. Ask a question, shake the puck (or tap), and a blue window swirls up a two-line fortune — plus a few printer-shop jokes like YES, ADD SUPPORTS.

Nothing goes to the internet. The whole toy lives on the chip.

What you will learn

  • What a state machine is (a program with a few “moods”).
  • How an accelerometer feels a shake.
  • How a list (array) of answers works, and how random picks one.
  • Why we draw off-screen first, then show the picture (no flicker).

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 round color screen (GC9A01 — 240 dots across and 240 down).
  • A CST816S touch glass, so a finger is a mouse.
  • A QMI8658 motion sensor (IMU) that feels a shake.
  • No Wi-Fi. The toy never joins a network. Ask, shake, and the answer all happen on the chip.
  • 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 fortune 8-ball
Desk Pal fortune 8-ball on Desk Pal puck (ESP32-S3).

Build it

1

Print the shell

Black PLA, 0.16–0.2 mm, 3 walls. Snap the puck in with USB-C in the slot. The screen stays USB-down on purpose so the 8 always reads the same way on a desk.

Print the shell
2

Flash the puck

Flash means copy the program onto the chip. Easiest path: use the attached fortune-8ball.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

Ask a question

Think a yes/no question. Shake, tap, or type shake on serial. Tap during the swirl to skip to the answer. Tap the fortune to put the 8 back.

Ask a question

Lesson 1 — Programs have moods (state machines)

This toy only ever does one of three things:

  1. Idle — showing the 8, waiting.
  2. Swirl — the blue triangle is spinning; the answer is already chosen but hidden.
  3. Reveal — the two lines fade in.

That pattern is a state machine. You will see it in games, traffic lights, and vending machines. The code is not “a thousand ifs.” It is “what mood am I in, and what event moves me to the next mood?”

Events here: a shake, a tap, or time running out.

The three moods

fortune-8ball.ino
enum State : uint8_t { ST_IDLE = 0, ST_SWIRL, ST_REVEAL };

// Idle  --shake/tap-->  Swirl  --time/tap-->  Reveal  --tap-->  Idle

Lesson 2 — The motion sensor feels gravity and jerks

Inside the puck is an IMU (inertial measurement unit). The part we use is an accelerometer. It measures acceleration — including Earth’s gravity, which feels like a steady 1 g downward when the puck is still.

A shake is a sudden change on top of that 1 g. The firmware keeps a running “how wild is this?” number. Putting the puck on the desk is ignored. A real wrist flick crosses a threshold for a few milliseconds, and that counts as shake.

If shake does nothing, type shake on serial. That skips the sensor and proves the rest of the program works — classic debugging: split the problem.

Ignore fidgets, catch a flick

fortune-8ball.ino
// Think of these as the "how hard is hard enough?" knobs.
static const float SHAKE_ENTER = 2.35f;   // must get this wild
static const uint32_t SHAKE_ARM_MS = 90;  // and stay wild this long
static const uint32_t SHAKE_COOL_MS = 900; // then rest so one shake ≠ five

Lesson 3 — Answers are just a list

A fortune is two short strings (top line, bottom line) so they fit in the triangle. The program keeps them in an array — a numbered list. When you ask, it picks an index with random. Same idea as drawing a card from a deck: the deck does not change, only which card you show.

A deck of answers

fortune-8ball.ino
struct Fortune { const char *a; const char *b; };

static const Fortune FORTUNES[] = {
    {"IT IS", "CERTAIN"},
    {"REPLY HAZY,", "TRY AGAIN"},
    {"MY REPLY", "IS NO"},
    {"YES, ADD", "SUPPORTS"},
};
// pick: FORTUNES[ random(0, how_many) ]

Lesson 4 — Draw the picture, then show it

If you draw triangle, text, triangle, text straight onto a live screen, you see half-finished frames — flicker. This firmware draws into a sprite (a picture in memory) and then pushes the finished picture once. That is the same idea as drawing on paper under the desk, then slapping the paper on the wall.

Try this

  • Add your own fortune to the list (keep each line short) and reflash.
  • Type fortune on serial to skip the swirl — useful when filming.
  • Type status. If imu is down, shake will not work, but tap still will.

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: tap · shake · fortune · 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….

Tip

The screen does not auto-rotate. That is on purpose so the 8 is always upright on a desk. Shake still works if you pick it up.


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 use Wi-Fi. There is no network name and no password in the file.

fortune-8ball.ino (complete)

fortune-8ball.ino
// Magic 8-ball for Waveshare ESP32-S3-Touch-LCD-1.28
// 1.28" round GC9A01, CST816S touch, QMI8658 IMU (Amazon B0CM68M8LR).
//
// Idle: glossy black ball with a faint 8. Shake the puck (or type "shake")
// and the blue window swirls up a rounded triangle with a fortune.
// Tap idle to ask; tap during the swirl to skip to the answer;
// tap a shown fortune to put the 8 back.
//
// Serial 115200: tap | shake | fortune | status | help
// Screen stays USB-down (no IMU auto-rotate). Shake still asks.
//
// Arduino IDE: ESP32S3 Dev Module, OPI PSRAM, 16MB flash, USB CDC On Boot.
// TFT_eSPI: apply firmware/TFT_eSPI_Setup.h (USE_HSPI_PORT).
//
// Mirror timings, fortunes, and geometry in sim/index.html.

#include <Arduino.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <math.h>
#include "s3_puck_pins.h"

// ---------------------------------------------------------- toy timing
// Keep in sync with sim/index.html
static const uint32_t SWIRL_MS = 1600;
static const uint32_t HARD_SWIRL_MS = 2400;
static const uint32_t REVEAL_FADE_MS = 280;

// Desk fidget / putting the puck down must not count. A real wrist flick does.
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;
static const float SHAKE_GYRO_DEAD = 130.0f;
static const uint32_t SHAKE_ARM_MS = 90;
static const uint32_t SHAKE_COOL_MS = 900;
static const float HARD_ENTER = 3.20f;

static const int CX = 120;
static const int CY = 120;
static const float WIN_R = 86.0f;
static const float TRI_TOP_X = 120, TRI_TOP_Y = 56;
static const float TRI_BL_X = 46, TRI_BL_Y = 180;
static const float TRI_BR_X = 194, TRI_BR_Y = 180;
static const float TRI_ROUND = 13.0f;
static const float TEXT_CX = 120, TEXT_CY = 132;
static const int LINE_H = 18;

// ---------------------------------------------------------- fortunes
struct Fortune {
  const char *a;
  const char *b;
};

static const Fortune FORTUNES[] = {
    {"IT IS", "CERTAIN"},
    {"WITHOUT A", "DOUBT"},
    {"YES", "DEFINITELY"},
    {"YOU MAY", "RELY ON IT"},
    {"AS I SEE", "IT, YES"},
    {"MOST", "LIKELY"},
    {"OUTLOOK", "GOOD"},
    {"SIGNS POINT", "TO YES"},
    {"REPLY HAZY,", "TRY AGAIN"},
    {"ASK AGAIN", "LATER"},
    {"BETTER NOT", "TELL YOU NOW"},
    {"DON'T COUNT", "ON IT"},
    {"MY REPLY", "IS NO"},
    {"OUTLOOK NOT", "SO GOOD"},
    {"VERY", "DOUBTFUL"},
    {"ASK YOUR", "PRINTER"},
    {"THE SPAGHETTI", "KNOWS"},
    {"YES, ADD", "SUPPORTS"},
    {"FILAMENT", "SAYS NO"},
    {"ABSOLUTELY", "NOT TODAY"},
};
static const int FORTUNE_COUNT = sizeof(FORTUNES) / sizeof(FORTUNES[0]);

enum State : uint8_t { ST_IDLE = 0, ST_SWIRL, ST_REVEAL };

static const char *STATE_NAMES[] = {"idle", "swirl", "reveal"};

// ---------------------------------------------------------- hardware
static TFT_eSPI tft;
static TFT_eSprite spr(&tft);

static uint8_t imuAddr = 0;
static bool imuOk = false;
static bool touchOk = false;
static volatile bool touchIrq = false;

static float accX = 0, accY = 0, accZ = 1;
static float gyrX = 0, gyrY = 0, gyrZ = 0;
static float shakeEnergy = 0;
static bool shaking = false;
static uint32_t shakeArmAt = 0;
static uint32_t shakeCoolUntil = 0;

static State state = ST_IDLE;
static uint32_t swirlAt = 0;
static uint32_t swirlMs = SWIRL_MS;
static bool hardSwirl = false;
static uint32_t revealAt = 0;
static bool instantReveal = false;
static int fortuneIndex = -1;
static int lastFortune = -1;

static bool fingerDown = false;
static uint32_t fingerDownAt = 0;
static int16_t touchX0 = 0, touchY0 = 0, touchX = 0, touchY = 0;

// ---------------------------------------------------------------- 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 float easeOut(float t) {
  t = clampf(t, 0, 1);
  const float u = 1.0f - t;
  return 1.0f - u * u * u;
}

static float easeInOut(float t) {
  t = clampf(t, 0, 1);
  return t * t * (3.0f - 2.0f * t);
}

static uint32_t since(uint32_t now, uint32_t then) { return now - then; }

static uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) { return spr.color565(r, g, b); }

static uint16_t mixRgb(uint8_t r1, uint8_t g1, uint8_t b1, uint8_t r2, uint8_t g2, uint8_t b2,
                       float t) {
  t = clampf(t, 0, 1);
  return rgb((uint8_t)lerpf(r1, r2, t), (uint8_t)lerpf(g1, g2, t), (uint8_t)lerpf(b1, b2, t));
}

static void printFortune() {
  if (fortuneIndex < 0) {
    Serial.println("fortune: (none)");
    return;
  }
  Serial.printf("fortune: %s %s\n", FORTUNES[fortuneIndex].a, FORTUNES[fortuneIndex].b);
}

static void pickFortune() {
  int next = (int)(esp_random() % (uint32_t)FORTUNE_COUNT);
  if (FORTUNE_COUNT > 1) {
    int guard = 0;
    while (next == lastFortune && guard++ < 8) {
      next = (int)(esp_random() % (uint32_t)FORTUNE_COUNT);
    }
  }
  fortuneIndex = next;
  lastFortune = next;
}

// ---------------------------------------------------------------------- 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);  // auto-increment
  imuWrite(S3_QMI_CTRL2, 0x23);  // ±8g
  imuWrite(S3_QMI_CTRL3, 0x43);  // ±256 dps
  imuWrite(S3_QMI_CTRL7, 0x03);  // accel + gyro on
  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));
  };
  accX = s16(buf[0], buf[1]) / S3_ACCEL_LSB_PER_G;
  accY = s16(buf[2], buf[3]) / S3_ACCEL_LSB_PER_G;
  accZ = s16(buf[4], buf[5]) / S3_ACCEL_LSB_PER_G;
  gyrX = s16(buf[6], buf[7]) / S3_GYRO_LSB_PER_DPS;
  gyrY = s16(buf[8], buf[9]) / S3_GYRO_LSB_PER_DPS;
  gyrZ = s16(buf[10], buf[11]) / S3_GYRO_LSB_PER_DPS;
  return true;
}

static void printHelp() {
  Serial.println("tap | shake | fortune | status | help");
  Serial.println("shake or tap idle to ask; tap a fortune to put the 8 back");
}

// ------------------------------------------------------------------- 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);  // stay awake while a finger is down
  touchWrite(0xFA, 0x60);  // IRQ on touch + change
  return true;
}

static bool readTouch(uint8_t *fingers, int16_t *x, int16_t *y) {
  Wire.beginTransmission(S3_CST816S_ADDR);
  Wire.write(0x02);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)S3_CST816S_ADDR, 5) < 5) return false;
  *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();
  *x = ((xh & 0x0F) << 8) | xl;
  *y = ((yh & 0x0F) << 8) | yl;
  return true;
}

static void IRAM_ATTR onTouchIrq() { touchIrq = true; }

// -------------------------------------------------------------- game
static void coolShake(uint32_t now) {
  shakeEnergy = 0;
  shaking = false;
  shakeArmAt = 0;
  shakeCoolUntil = now + SHAKE_COOL_MS;
}

static void goIdle() {
  state = ST_IDLE;
  coolShake(millis());
  Serial.println("idle");
}

static void startSwirl(uint32_t now, bool hard) {
  pickFortune();
  state = ST_SWIRL;
  swirlAt = now;
  swirlMs = hard ? HARD_SWIRL_MS : SWIRL_MS;
  hardSwirl = hard;
  instantReveal = false;
  coolShake(now);
  Serial.printf("shake%s\n", hard ? " (hard)" : "");
}

static void startReveal(uint32_t now) {
  state = ST_REVEAL;
  revealAt = now;
  coolShake(now);
  printFortune();
}

static void onTap() {
  const uint32_t now = millis();
  if (state == ST_SWIRL) {
    startReveal(now);
    return;
  }
  if (state == ST_REVEAL) {
    goIdle();
    return;
  }
  startSwirl(now, false);
}

static void updateMotion(uint32_t now) {
  if (!imuOk) return;
  if (!imuRead()) return;

  if (fingerDown) {
    shakeEnergy *= 0.65f;
    shaking = false;
    shakeArmAt = 0;
    return;
  }

  const float mag = sqrtf(accX * accX + accY * accY + accZ * accZ);
  const float dev = fabsf(mag - 1.0f);
  const float gyro = sqrtf(gyrX * gyrX + gyrY * gyrY + gyrZ * gyrZ);
  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 && (int32_t)(now - shakeArmAt) >= (int32_t)SHAKE_ARM_MS) shaking = true;
  } else {
    shakeArmAt = 0;
  }
  if (shaking && shakeEnergy < SHAKE_EXIT) shaking = false;

  if ((int32_t)(now - shakeCoolUntil) < 0) return;
  if (state != ST_IDLE) return;
  if (shaking) startSwirl(now, shakeEnergy > HARD_ENTER);
}

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;

  uint8_t fingers = 0;
  int16_t x = 0, y = 0;
  const bool ok = readTouch(&fingers, &x, &y);
  const bool down = ok && fingers > 0;

  if (down) {
    touchX = x;
    touchY = y;
    if (!fingerDown) {
      fingerDown = true;
      fingerDownAt = now;
      touchX0 = x;
      touchY0 = y;
    }
    return;
  }

  if (fingerDown) {
    fingerDown = false;
    const int16_t dx = touchX - touchX0;
    const int16_t dy = touchY - touchY0;
    if (abs(dx) < 40 && abs(dy) < 40 && since(now, fingerDownAt) < 600) onTap();
  }
}

static void printStatus() {
  Serial.printf("state=%s fortune=%d energy=%.2f shaking=%d imu=%d touch=%d\n",
                STATE_NAMES[state], fortuneIndex, shakeEnergy, shaking, imuOk, touchOk);
  if (fortuneIndex >= 0) printFortune();
}

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) {
      line = "";
      continue;
    }
    String cmd = line;
    cmd.toLowerCase();
    const uint32_t now = millis();

    if (cmd == "tap") {
      onTap();
    } else if (cmd == "shake") {
      if (state != ST_SWIRL) startSwirl(now, false);
    } else if (cmd == "fortune") {
      pickFortune();
      instantReveal = true;
      startReveal(now);
    } else if (cmd == "status") {
      printStatus();
    } else if (cmd == "help") {
      printHelp();
    } else {
      Serial.println("? (try help)");
    }
    line = "";
  }
}

// ----------------------------------------------------------------- draw
static void toward(float fx, float fy, float tx, float ty, float dist, float *ox, float *oy) {
  const float dx = tx - fx, dy = ty - fy;
  const float len = sqrtf(dx * dx + dy * dy);
  if (len < 0.5f) {
    *ox = fx;
    *oy = fy;
    return;
  }
  *ox = fx + dx / len * dist;
  *oy = fy + dy / len * dist;
}

// Chamfered / softly rounded triangle (no Mickey-mouse corner blobs).
static void fillRoundTri(float x1, float y1, float x2, float y2, float x3, float y3, float r,
                         uint16_t c) {
  float a2x, a2y, a3x, a3y, b1x, b1y, b3x, b3y, c1x, c1y, c2x, c2y;
  toward(x1, y1, x2, y2, r, &a2x, &a2y);
  toward(x1, y1, x3, y3, r, &a3x, &a3y);
  toward(x2, y2, x1, y1, r, &b1x, &b1y);
  toward(x2, y2, x3, y3, r, &b3x, &b3y);
  toward(x3, y3, x1, y1, r, &c1x, &c1y);
  toward(x3, y3, x2, y2, r, &c2x, &c2y);
  spr.fillTriangle((int)a2x, (int)a2y, (int)b1x, (int)b1y, (int)b3x, (int)b3y, c);
  spr.fillTriangle((int)a2x, (int)a2y, (int)b3x, (int)b3y, (int)c2x, (int)c2y, c);
  spr.fillTriangle((int)a2x, (int)a2y, (int)c2x, (int)c2y, (int)c1x, (int)c1y, c);
  spr.fillTriangle((int)a2x, (int)a2y, (int)c1x, (int)c1y, (int)a3x, (int)a3y, c);
  const int ir = (int)(r * 0.55f);
  spr.fillCircle((int)a2x, (int)a2y, ir, c);
  spr.fillCircle((int)a3x, (int)a3y, ir, c);
  spr.fillCircle((int)b1x, (int)b1y, ir, c);
  spr.fillCircle((int)b3x, (int)b3y, ir, c);
  spr.fillCircle((int)c1x, (int)c1y, ir, c);
  spr.fillCircle((int)c2x, (int)c2y, ir, c);
}

static void drawBallSkin(uint32_t now) {
  spr.fillSprite(rgb(4, 4, 6));
  // soft body — concentric shade so the LCD reads as a sphere
  spr.fillCircle(CX, CY, 118, rgb(10, 10, 12));
  spr.fillCircle(CX, CY, 108, rgb(6, 6, 8));
  spr.fillCircle(CX + 4, CY + 10, 92, rgb(2, 2, 4));

  const float gleam = 0.5f + 0.5f * sinf(now * 0.0011f);
  const int hx = (int)(78 + gleam * 8);
  const int hy = (int)(58 + gleam * 4);
  spr.fillEllipse(hx, hy, 38, 18, rgb(28, 28, 34));
  spr.fillEllipse(hx - 6, hy - 4, 22, 10, rgb(48, 48, 56));
  spr.fillCircle(68, 52, 7, rgb(70, 70, 80));
  spr.fillCircle(54, 168, 18, rgb(8, 8, 10));
}

static void drawFaintEight(float fade) {
  if (fade <= 0.02f) return;
  const uint8_t halo = (uint8_t)(70 * fade);
  const uint8_t plate = (uint8_t)(186 * fade);
  const uint8_t ink = (uint8_t)(14 + 4 * fade);
  spr.fillCircle(CX, CY, 56, rgb(halo, halo, halo + 4));
  spr.fillCircle(CX, CY, 50, rgb(plate, plate + 2, plate + 8));
  const uint16_t k = rgb(ink, ink, ink + 2);
  const uint16_t p = rgb(plate, plate + 2, plate + 8);
  spr.fillCircle(CX, 104, 17, k);
  spr.fillCircle(CX, 104, 8, p);
  spr.fillCircle(CX, 138, 20, k);
  spr.fillCircle(CX, 138, 9, p);
  spr.fillRect(CX - 7, 116, 14, 14, k);
}

static void drawWindow(float radius, float swirlU, uint32_t now, bool hard) {
  if (radius < 2.0f) return;
  const int R = (int)radius;
  spr.fillCircle(CX, CY, R, rgb(6, 22, 78));
  spr.fillCircle(CX, CY, max(1, R - 5), rgb(12, 48, 140));
  spr.fillCircle(CX, CY + 6, max(1, R - 16), rgb(8, 32, 110));

  const float t = now * 0.001f;
  const float speed = hard ? 3.2f : 2.0f;
  const int n = hard ? 18 : 12;
  const float grow = clampf(radius / WIN_R, 0, 1);
  for (int i = 0; i < n; i++) {
    const float ang = t * speed + i * 0.52f;
    const float rad = (16.0f + (i % 5) * 11.0f + sinf(t * 3.0f + i) * 9.0f) * grow;
    const float px = CX + cosf(ang) * rad;
    const float py = CY + sinf(ang) * rad * 0.88f;
    const float dx = px - CX, dy = py - CY;
    if (dx * dx + dy * dy > (radius - 4) * (radius - 4)) continue;
    const int sz = 2 + (i % 4);
    const uint16_t c = (i % 3 == 0) ? rgb(70, 150, 230) : rgb(20, 70, 170);
    spr.fillCircle((int)px, (int)py, sz, c);
  }

  // calm the streaks as we settle
  if (swirlU > 0.7f) {
    const float hush = easeOut((swirlU - 0.7f) / 0.3f);
    const int veil = (int)(R * (0.92f));
    if (veil > 4) spr.fillCircle(CX, CY, veil, mixRgb(12, 48, 140, 8, 30, 108, hush));
  }

  spr.drawCircle(CX, CY, max(1, R - 1), rgb(120, 170, 230));
  spr.fillEllipse(CX - 18, CY - (int)(radius * 0.42f), (int)(radius * 0.38f),
                  (int)(radius * 0.14f), rgb(70, 120, 200));
}

static void drawTriangle(float rise, float fade) {
  if (fade <= 0.02f) return;
  const float dy = (1.0f - rise) * 28.0f;
  const float x1 = TRI_TOP_X, y1 = TRI_TOP_Y + dy;
  const float x2 = TRI_BL_X, y2 = TRI_BL_Y + dy;
  const float x3 = TRI_BR_X, y3 = TRI_BR_Y + dy;

  const float mx = (x1 + x2 + x3) / 3.0f;
  const float my = (y1 + y2 + y3) / 3.0f;
  auto puff = [&](float x, float y, float amt, float *ox, float *oy) {
    const float dx = x - mx, dyv = y - my;
    const float len = sqrtf(dx * dx + dyv * dyv);
    *ox = x + dx / len * amt;
    *oy = y + dyv / len * amt;
  };
  float e1x, e1y, e2x, e2y, e3x, e3y, i1x, i1y, i2x, i2y, i3x, i3y;
  puff(x1, y1, 4, &e1x, &e1y);
  puff(x2, y2, 4, &e2x, &e2y);
  puff(x3, y3, 4, &e3x, &e3y);
  puff(x1, y1, -12, &i1x, &i1y);
  puff(x2, y2, -12, &i2x, &i2y);
  puff(x3, y3, -12, &i3x, &i3y);

  const uint16_t edge = mixRgb(12, 48, 140, 200, 220, 250, fade);
  const uint16_t face = mixRgb(12, 48, 140, 6, 14, 40, fade);
  const uint16_t inner = mixRgb(12, 48, 140, 10, 22, 56, fade);

  fillRoundTri(e1x, e1y, e2x, e2y, e3x, e3y, TRI_ROUND, edge);
  fillRoundTri(x1, y1, x2, y2, x3, y3, TRI_ROUND, face);
  fillRoundTri(i1x, i1y, i2x, i2y, i3x, i3y, TRI_ROUND - 3, inner);
}

static void drawFortuneText(float fade, float bob) {
  if (fade <= 0.04f || fortuneIndex < 0) return;
  const Fortune &f = FORTUNES[fortuneIndex];
  const uint16_t col = mixRgb(6, 14, 40, 255, 255, 242, fade);
  spr.setTextFont(2);
  spr.setTextSize(1);
  spr.setTextDatum(MC_DATUM);
  spr.setTextWrap(false);
  spr.setTextColor(col, col);
  const int y = (int)(TEXT_CY + bob);
  spr.drawString(f.a, (int)TEXT_CX, y - LINE_H / 2);
  spr.drawString(f.b, (int)TEXT_CX, y + LINE_H / 2);
}

static void drawFrame(uint32_t now) {
  drawBallSkin(now);

  float eight = 1.0f;
  float winR = 0;
  float swirlU = 0;
  float triRise = 0;
  float triFade = 0;
  float textFade = 0;
  float bob = 0;
  bool hard = hardSwirl;

  if (state == ST_IDLE) {
    eight = 1.0f;
  } else if (state == ST_SWIRL) {
    swirlU = clampf(since(now, swirlAt) / (float)swirlMs, 0, 1);
    eight = 1.0f - easeOut(clampf(swirlU / 0.22f, 0, 1));
    winR = WIN_R * easeOut(clampf(swirlU / 0.38f, 0, 1));
    triFade = easeInOut(clampf((swirlU - 0.52f) / 0.30f, 0, 1));
    triRise = easeOut(clampf((swirlU - 0.48f) / 0.40f, 0, 1));
    textFade = easeInOut(clampf((swirlU - 0.82f) / 0.16f, 0, 1));
  } else {
    eight = 0;
    winR = WIN_R;
    swirlU = 1;
    triRise = 1;
    triFade = 1;
    if (instantReveal) {
      textFade = easeInOut(clampf(since(now, revealAt) / (float)REVEAL_FADE_MS, 0, 1));
    } else {
      textFade = 1;
    }
    bob = sinf(now * 0.00145f) * 2.4f;
  }

  drawFaintEight(eight);
  if (winR > 0) drawWindow(winR, state == ST_SWIRL ? swirlU : 1.0f, now, hard);
  if (triFade > 0) {
    drawTriangle(triRise, triFade);
    drawFortuneText(textFade, bob);
  }

  spr.pushSprite(0, 0);
}

// ------------------------------------------------------------------- setup
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(S3_LCD_SIZE, S3_LCD_SIZE) == nullptr) {
    Serial.println("sprite alloc failed");
    while (true) delay(1000);
  }

  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();

  randomSeed(esp_random());
  Serial.println(imuOk ? "QMI8658 ready" : "QMI8658 not found — shake via serial");
  Serial.println(touchOk ? "CST816S ready" : "CST816S not found — tap via serial fortune");
  Serial.println("fortune 8-ball ready (type 'help')");
}

void loop() {
  const uint32_t now = millis();
  handleSerial();
  handleTouch(now);
  updateMotion(now);

  if (state == ST_SWIRL && since(now, swirlAt) >= swirlMs) startReveal(now);

  drawFrame(now);
}

Board and housing

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