Lucky slab slot machine

A neon three-reel bandit locked landscape on the C6. Short BOOT spins. Hold BOOT cycles Vegas, Diner, and Cyber cabinets.

Lucky slab slot machine
DifficultyBeginner
Build time40 min
Est. cost$18
Parts4
Files5
Steps3

Things you'll need

QtyPartType
1
Waveshare ESP32-C6-LCD-1.47 (silhouette slab)
Waveshare ESP32-C6-LCD-1.47 (silhouette slab) 172×320 ST7789, GPIO8 WS2812, TF slot, BOOT/RESET. No touch or IMU. Amazon B0DK5J6LX3.
electronics
1
Casino cabinet + dummy lever
Casino cabinet + dummy lever Landscape cabinet with a dummy lever and kickstand. Print the lever in gold PLA.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 12–20 g for the snap shells.
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 neon three-reel slot machine, locked landscape. Short BOOT pulls the lever (1 credit). Hold BOOT cycles Vegas / Diner / Cyber cabinets.

Pair pays 3, triple pays 24, 7-7-7 pays 77. You start with 24 credits. Wins shout in green; losses in red. Serial jackpot forces 7-7-7 for a video.

This is a toy. Credits are a number in memory. They are not money.

What you will learn

  • How random() picks a symbol, and why that is probability, not luck magic.
  • How a pay table is just numbers in #defines.
  • How a state machine runs idle → spin → win/lose.
  • Why the reels are drawn in a clip (so fruit does not leak outside the windows).

Meet the silhouette slab

This project uses the Waveshare ESP32-C6-LCD-1.47 — a slim rectangle with a 1.47 inch screen.

  • A 172×320 color screen. You can turn it portrait or landscape in software.
  • No touch. No motion sensor. Interaction is the BOOT button (a short press vs a ~0.7 second hold).
  • A single RGB LED on the board (GPIO8) that can glow any color.
  • Wi-Fi that is 2.4 GHz only.
  • Keep the backlight at 50%. Full brightness can leave a heat spot on the panel.

The program is a .ino file (Arduino-style C++). You will not memorize it. You will learn the ideas, then see the small piece of code that does each job.

Note

Word to know — GPIO. It means “general purpose input/output” — a pin the chip can turn on or read. BOOT is GPIO9. The RGB LED is GPIO8. You do not rewire anything; the board already connected those pins.

The finished gadget

Lucky slab slot machine
Lucky slab slot machine on Silhouette slab (ESP32-C6).

Build it

1

Print the cabinet

0.2 mm. A gold dummy lever is optional — the real lever is BOOT. The slab sits landscape in the window.

Print the cabinet
2

Flash the slab

Flash means copy the program onto the chip. Easiest path: use the attached lucky-slab.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32C6 Dev Module, USB CDC On Boot, 115200 — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. Keep the backlight at 50%. This slab has no touch screen and no motion sensor — you press BOOT (the button on GPIO9) to interact.

Flash the slab
3

Pull the lever

Short BOOT = spin. Hold BOOT = next theme. Serial credits 24 if you go broke while testing.

Pull the lever
Note

Credits persist on the chip. They are for learning randomness, not for gambling. House edge is not the lesson — fair random() is.

Lesson 1 — Random is a number generator

Each reel picks random(0, 7) — seven symbols (7, BAR, STAR, GEM, CHERRY, BELL, LEMON). If every symbol is equally likely, a triple is rare and 7-7-7 is rarer still (one of 343 combinations). You can compute that: 7 × 7 × 7 = 343.

Computers use pseudo-random numbers: a recipe that looks mixed. Good enough for a desk toy. Not enough for a casino — and we do not want a casino.

The pay table is homework you can check

lucky-slab.ino
#define START_CREDITS 24
#define SPIN_COST 1
#define PAIR_PAY 3
#define TRIPLE_PAY 24
#define SEVEN_PAY 77

// After a spin:
//   three 7s → +77, coins rain
//   any other triple → +24, green banner
//   any pair → +3, green banner
//   else → 0, red banner

Lesson 2 — Compare three variables

Settle is an if you can write on paper:

  • If A == B and B == C → triple (then check if A is 7).
  • Else if A == B or B == C or A == C → pair.
  • Else → miss.

That is boolean logic. The banners (WINNER, SO CLOSE) are just strings from two lists, cycled so the toy does not say the same word every time.

Lesson 3 — Clip the reels so art stays in the window

Early versions drew fruit that spilled into the cabinet chrome, and the whole screen flickered. Two canvases fixed it:

  • A full frame (the neon box).
  • A reel-sized clip — draw the symbol there, then stamp that clip into each window.

Then one blit to the LCD. Same “compose then show” lesson as the GIF player. When something looks broken on a screen, ask: am I drawing live, or into a buffer?

Try this

  • Spin 20 times and tally pairs / triples. Compare to 1/7 and 1/49 gut-checks.
  • Type jackpot and watch the coin rain. That function forces 7-7-7 — useful for film, honest on serial.
  • Change PAIR_PAY to 1 and feel how the toy’s economy changes.

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: spin · theme [vegas|diner|cyber] · credits [n] · jackpot · 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.

Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.

lucky-slab.ino (complete)

lucky-slab.ino
// Lucky Slab — one-armed bandit for Waveshare ESP32-C6-LCD-1.47
// (Amazon B0DK5J6LX3). LCD-only slab — no touch, no IMU.
//
// Landscape 320×172 the whole time. Three chunky reels, neon cabinet,
// coin rain on JACKPOT. People film the 7-7-7.
//
// Short BOOT (GPIO9): pull the lever.
// Hold BOOT ~0.7s: cycle Vegas / Diner / Cyber cabinet.
// Serial 115200: spin | theme | credits | jackpot | help | status
//
// Backlight stays at 50% (Waveshare heat-spot warning).
// The browser sim is board-sketches/lucky-slab/sim/index.html
//   python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/lucky-slab

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <math.h>

#include "../../../shared/c6_slab_pins.h"

#define BOOT_LONG_MS 700UL
#define N_REEL 3
#define N_SYM 7
#define N_COIN 14
#define START_CREDITS 24
#define SPIN_COST 1
#define PAIR_PAY 3
#define TRIPLE_PAY 24
#define SEVEN_PAY 77

enum Sym : uint8_t { SYM_SEVEN, SYM_BAR, SYM_STAR, SYM_GEM, SYM_CHERRY, SYM_BELL, SYM_LEMON };
enum Phase : uint8_t { PH_IDLE, PH_SPIN, PH_WIN, PH_JACKPOT, PH_LOSE, PH_BROKE };
enum ThemeId : uint8_t { TH_VEGAS, TH_DINER, TH_CYBER, TH_COUNT };

struct Theme {
  const char *id;
  const char *title;
  uint8_t bg[3], panel[3], gold[3], ink[3], accent[3], dim[3];
};

// Keep RGB triples in sync with sim/index.html
static const Theme THEMES[TH_COUNT] = {
  {"vegas", "LUCKY",  {18, 6, 28},  {42, 12, 58},  {255, 196, 64}, {255, 236, 190}, {255, 64, 160}, {90, 40, 110}},
  {"diner", "DINER",   {36, 10, 12}, {72, 16, 18},  {255, 214, 96}, {255, 240, 220}, {255, 72, 64},  {110, 40, 42}},
  {"cyber", "NEON",   {4, 10, 18},   {8, 28, 42},   {80, 255, 210}, {210, 255, 246}, {40, 180, 255}, {20, 50, 70}},
};

static const char *SYM_NAME[N_SYM] = {"7", "BAR", "STAR", "GEM", "CHERRY", "BELL", "LEMON"};

static const char *const WIN_LINES[] = {
    "WINNER", "NICE HIT", "PAYOUT", "CHA-CHING", "HOT STREAK", "CASH IN", "LADY LUCK"};
static const char *const LOSE_LINES[] = {
    "OH NO", "SO CLOSE", "UNLUCKY", "NO DICE", "TRY AGAIN", "BETTER LUCK NEXT TIME"};
static const uint8_t N_WIN_LINES = sizeof(WIN_LINES) / sizeof(WIN_LINES[0]);
static const uint8_t N_LOSE_LINES = sizeof(LOSE_LINES) / sizeof(LOSE_LINES[0]);
static const uint16_t COL_WIN = ((40 & 0xF8) << 8) | ((220 & 0xFC) << 3) | (88 >> 3);
static const uint16_t COL_LOSE = ((255 & 0xF8) << 8) | ((40 & 0xFC) << 3) | (48 >> 3);

static const int FB_W = 320;
static const int FB_H = 172;
static const int REEL_W = 88;
static const int REEL_H = 108;
static const int REEL_INSET = 3;
static const int REEL_IW = REEL_W - 2 * REEL_INSET;
static const int REEL_IH = REEL_H - 2 * REEL_INSET;

static Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
static Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0 /* rotation; setRotation(1) in setup */, true /* IPS */,
    C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);
// Compose in RAM and blit once — live fillCircle/fillTriangle tears this ST7789.
static Arduino_Canvas *fb = nullptr;
static Arduino_Canvas *reelFb = nullptr;
static Arduino_GFX *g = nullptr;

static Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
static Preferences prefs;

static ThemeId g_theme = TH_VEGAS;
static Phase g_phase = PH_IDLE;
static int g_credits = START_CREDITS;
static int g_lastWin = 0;
static uint8_t g_stop[N_REEL] = {SYM_SEVEN, SYM_CHERRY, SYM_BAR};
static float g_start[N_REEL] = {0, 2, 4};
static float g_end[N_REEL] = {0, 2, 4};
static uint32_t g_t0[N_REEL] = {0, 0, 0};
static uint32_t g_dur[N_REEL] = {1100, 1480, 1860};
static float g_pos[N_REEL] = {0, 2, 4};
static bool g_done[N_REEL] = {true, true, true};

struct Coin {
  float x, y, vy, r;
  uint8_t rgb[3];
};
static Coin g_coins[N_COIN];
static int g_coinN = 0;
static uint32_t g_phaseUntil = 0;
static uint32_t g_bannerUntil = 0;
static const char *g_banner = "";
static uint8_t g_winI = 0;
static uint8_t g_loseI = 0;
static bool g_bootDown = false;
static bool g_bootLong = false;
static uint32_t g_bootDownAt = 0;
static String g_line;
static uint32_t g_lastDraw = 0;

static int sw() { return FB_W; }
static int sh() { return FB_H; }

static uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
  return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3));
}
static uint16_t rgb565a(const uint8_t *c) { return rgb565(c[0], c[1], c[2]); }

static float clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); }
static float easeOutCubic(float t) {
  t = clampf(t, 0, 1);
  const float u = 1 - t;
  return 1 - u * u * u;
}
static float wrapSym(float p) {
  while (p < 0) p += N_SYM;
  while (p >= N_SYM) p -= N_SYM;
  return p;
}
static float forwardTo(float from, int target, int extraTurns) {
  const float cur = wrapSym(from);
  float d = (float)target - cur;
  if (d < 0.12f) d += N_SYM;
  return from + extraTurns * N_SYM + d;
}

static void persist() {
  prefs.putUChar("theme", (uint8_t)g_theme);
  prefs.putShort("credits", (int16_t)g_credits);
}

static void fillPill(int x, int y, int w, int h, uint16_t c) {
  int r = h / 2;
  if (r < 2) r = 2;
  if (w < r * 2) w = r * 2;
  g->fillRect(x + r, y, w - 2 * r, h, c);
  g->fillCircle(x + r, y + r, r, c);
  g->fillCircle(x + w - r - 1, y + r, r, c);
}

static void printCentered(const char *s, int y, int size, uint16_t fg, uint16_t bg) {
  const int tw = (int)strlen(s) * 6 * size;
  g->setTextSize(size);
  g->setTextColor(fg, bg);
  g->setCursor((sw() - tw) / 2, y);
  g->print(s);
}

static void drawSeven(Arduino_GFX *d, int cx, int cy, int s, uint16_t fg) {
  d->setTextSize(s >= 28 ? 4 : 3);
  d->setTextColor(fg);
  const int tw = 6 * (s >= 28 ? 4 : 3);
  const int th = 8 * (s >= 28 ? 4 : 3);
  d->setCursor(cx - tw / 2, cy - th / 2);
  d->print('7');
}

static void drawBar(Arduino_GFX *d, int cx, int cy, int s, uint16_t fg, uint16_t bg) {
  const int w = s + 10, h = s / 3 + 4;
  d->fillRect(cx - w / 2, cy - h / 2, w, h, fg);
  d->setTextSize(1);
  d->setTextColor(bg, fg);
  d->setCursor(cx - 9, cy - 3);
  d->print("BAR");
}

static void drawStar(Arduino_GFX *d, int cx, int cy, int s, uint16_t fg) {
  const int r = s / 2;
  d->fillTriangle(cx, cy - r, cx - r / 3, cy, cx + r / 3, cy, fg);
  d->fillTriangle(cx, cy + r, cx - r / 3, cy, cx + r / 3, cy, fg);
  d->fillTriangle(cx - r, cy, cx, cy - r / 3, cx, cy + r / 3, fg);
  d->fillTriangle(cx + r, cy, cx, cy - r / 3, cx, cy + r / 3, fg);
  d->fillCircle(cx, cy, r / 4 + 1, fg);
}

static void drawGem(Arduino_GFX *d, int cx, int cy, int s, uint16_t fg, uint16_t hi) {
  const int w = s / 2 + 4, h = s / 2 + 6;
  d->fillTriangle(cx, cy - h, cx - w, cy, cx + w, cy, fg);
  d->fillTriangle(cx, cy + h, cx - w, cy, cx + w, cy, hi);
}

static void drawCherry(Arduino_GFX *d, int cx, int cy, int s, uint16_t red, uint16_t stem) {
  const int r = s / 5 + 4;
  d->fillCircle(cx - r, cy + 2, r, red);
  d->fillCircle(cx + r + 1, cy + 4, r, red);
  d->drawLine(cx - r, cy - r, cx, cy - s / 3, stem);
  d->drawLine(cx + r + 1, cy - r + 2, cx, cy - s / 3, stem);
  d->fillCircle(cx, cy - s / 3, 2, stem);
}

static void drawBell(Arduino_GFX *d, int cx, int cy, int s, uint16_t fg, uint16_t lip) {
  const int r = s / 3 + 2;
  d->fillCircle(cx, cy - 2, r, fg);
  d->fillRect(cx - r - 2, cy + r / 2, r * 2 + 4, 5, lip);
  d->fillCircle(cx, cy + r / 2 + 6, 3, fg);
}

static void drawLemon(Arduino_GFX *d, int cx, int cy, int s, uint16_t fg, uint16_t leaf) {
  const int rx = s / 2, ry = s / 3;
  d->fillEllipse(cx, cy + 2, rx, ry, fg);
  d->fillTriangle(cx + rx / 2, cy - ry, cx + rx, cy - ry - 4, cx + rx - 2, cy - 2, leaf);
}

static void drawSymbol(Arduino_GFX *d, int cx, int cy, int s, uint8_t id, const Theme &T) {
  const uint16_t gold = rgb565a(T.gold);
  const uint16_t accent = rgb565a(T.accent);
  const uint16_t ink = rgb565a(T.ink);
  const uint16_t panel = rgb565a(T.panel);
  switch (id) {
    case SYM_SEVEN: drawSeven(d, cx, cy, s, accent); break;
    case SYM_BAR: drawBar(d, cx, cy, s, gold, panel); break;
    case SYM_STAR: drawStar(d, cx, cy, s, gold); break;
    case SYM_GEM: drawGem(d, cx, cy, s, rgb565(80, 230, 255), rgb565(40, 140, 220)); break;
    case SYM_CHERRY: drawCherry(d, cx, cy, s, accent, rgb565(40, 160, 70)); break;
    case SYM_BELL: drawBell(d, cx, cy, s, gold, ink); break;
    default: drawLemon(d, cx, cy, s, gold, rgb565(40, 180, 70)); break;
  }
}

static void spawnCoins() {
  const Theme &T = THEMES[g_theme];
  g_coinN = N_COIN;
  for (int i = 0; i < N_COIN; i++) {
    g_coins[i].x = 16 + (i * 23) % 290;
    g_coins[i].y = -8.0f - (i % 5) * 10;
    g_coins[i].vy = 28 + (i * 7) % 40;
    g_coins[i].r = 3 + (i % 3);
    if (i & 1) {
      g_coins[i].rgb[0] = T.gold[0];
      g_coins[i].rgb[1] = T.gold[1];
      g_coins[i].rgb[2] = T.gold[2];
    } else {
      g_coins[i].rgb[0] = T.ink[0];
      g_coins[i].rgb[1] = T.ink[1];
      g_coins[i].rgb[2] = T.ink[2];
    }
  }
}

static void stepCoins(float dt) {
  for (int i = 0; i < g_coinN; i++) {
    g_coins[i].vy += 180 * dt;
    g_coins[i].y += g_coins[i].vy * dt;
    if (g_coins[i].y > sh() + 8) {
      g_coins[i].y = -10;
      g_coins[i].vy = 24 + (i * 9) % 36;
      g_coins[i].x = 12 + (random(0, 296));
    }
  }
}

static void drawCoins() {
  for (int i = 0; i < g_coinN; i++) {
    g->fillCircle((int)g_coins[i].x, (int)g_coins[i].y, (int)g_coins[i].r,
                  rgb565(g_coins[i].rgb[0], g_coins[i].rgb[1], g_coins[i].rgb[2]));
  }
}

static void reelWindow(int i, int &x, int &y, int &w, int &h) {
  const int gap = 8;
  w = REEL_W;
  h = REEL_H;
  const int total = N_REEL * w + (N_REEL - 1) * gap;
  x = (sw() - total) / 2 + i * (w + gap);
  y = 32;
}

// Paint cabinet background everywhere in the well except the three inner windows.
static void maskAroundReels(const Theme &T) {
  const uint16_t bg = rgb565a(T.bg);
  int x0, x1, x2, y, w, h;
  reelWindow(0, x0, y, w, h);
  reelWindow(1, x1, y, w, h);
  reelWindow(2, x2, y, w, h);
  const int iy = y + REEL_INSET;
  const int ih = h - 2 * REEL_INSET;
  const int wellTop = 26;
  const int wellBot = sh() - 24;
  g->fillRect(0, wellTop, sw(), iy - wellTop, bg);
  g->fillRect(0, iy + ih, sw(), wellBot - (iy + ih), bg);
  g->fillRect(0, iy, x0 + REEL_INSET, ih, bg);
  g->fillRect(x0 + w - REEL_INSET, iy, (x1 + REEL_INSET) - (x0 + w - REEL_INSET), ih, bg);
  g->fillRect(x1 + w - REEL_INSET, iy, (x2 + REEL_INSET) - (x1 + w - REEL_INSET), ih, bg);
  g->fillRect(x2 + w - REEL_INSET, iy, sw() - (x2 + w - REEL_INSET), ih, bg);
}

static void drawReelSymbols(Arduino_GFX *d, int originX, int originY, int i, const Theme &T) {
  const float pos = wrapSym(g_pos[i]);
  const int mid = (int)floorf(pos);
  const float frac = pos - mid;
  const int cell = 34;
  const int cx = originX + REEL_W / 2;
  const int cy = originY + REEL_H / 2;
  for (int k = -1; k <= 2; k++) {
    const int id = (mid + k + N_SYM * 4) % N_SYM;
    const int sy = cy + (int)roundf((k - frac) * cell);
    drawSymbol(d, cx, sy, 30, (uint8_t)id, T);
  }
}

static void drawReel(int i, const Theme &T) {
  int x, y, w, h;
  reelWindow(i, x, y, w, h);
  const uint16_t panel = rgb565a(T.panel);
  const uint16_t dim = rgb565a(T.dim);
  g->fillRect(x, y, w, h, panel);

  if (reelFb) {
    reelFb->fillScreen(dim);
    drawReelSymbols(reelFb, -REEL_INSET, -REEL_INSET, i, T);
    g->draw16bitRGBBitmap(x + REEL_INSET, y + REEL_INSET, reelFb->getFramebuffer(), REEL_IW, REEL_IH);
  } else {
    g->fillRect(x + REEL_INSET, y + REEL_INSET, w - 2 * REEL_INSET, h - 2 * REEL_INSET, dim);
    drawReelSymbols(g, x, y, i, T);
  }

}

static void drawReelFrames(const Theme &T) {
  const uint16_t gold = rgb565a(T.gold);
  for (int i = 0; i < N_REEL; i++) {
    int x, y, w, h;
    reelWindow(i, x, y, w, h);
    g->drawRect(x, y, w, h, gold);
    g->drawRect(x + 1, y + 1, w - 2, h - 2, gold);
  }
}

static void setLed(uint8_t r, uint8_t g, uint8_t b) {
  rgb.setPixelColor(0, rgb.Color(r, g, b));
  rgb.show();
}

static void pulseLed(uint32_t now) {
  const Theme &T = THEMES[g_theme];
  const float u = 0.55f + 0.45f * sinf(now / 280.0f);
  if (g_phase == PH_SPIN) {
    setLed(220, 220, 220);
  } else if (g_phase == PH_JACKPOT) {
    const float h = fmodf(now / 8.0f, 360.0f);
    // cheap 3-stop rainbow
    if (h < 120) setLed(255, (uint8_t)(h * 2), 40);
    else if (h < 240) setLed((uint8_t)(255 - (h - 120) * 2), 255, 40);
    else setLed(40, (uint8_t)(255 - (h - 240) * 2), 255);
  } else if (g_phase == PH_WIN) {
    setLed((uint8_t)(40 * u), (uint8_t)(220 * u), (uint8_t)(88 * u));
  } else if (g_phase == PH_LOSE || g_phase == PH_BROKE) {
    setLed(90, 16, 16);
  } else {
    setLed((uint8_t)(T.accent[0] * u * 0.7f), (uint8_t)(T.accent[1] * u * 0.7f),
           (uint8_t)(T.accent[2] * u * 0.7f));
  }
}

static void drawChrome(const Theme &T) {
  const uint16_t gold = rgb565a(T.gold);
  const uint16_t ink = rgb565a(T.ink);
  const uint16_t accent = rgb565a(T.accent);
  g->fillRect(0, 0, sw(), 26, rgb565a(T.panel));
  g->fillRect(0, sh() - 24, sw(), 24, rgb565a(T.panel));
  g->fillRect(0, 0, sw(), 3, gold);
  g->fillRect(0, sh() - 3, sw(), 3, gold);
  printCentered(T.title, 6, 2, gold, rgb565a(T.panel));

  char foot[28];
  if (g_phase == PH_SPIN) {
    snprintf(foot, sizeof(foot), "SPINNING");
    printCentered(foot, sh() - 18, 2, ink, rgb565a(T.panel));
  } else if (g_phase == PH_JACKPOT) {
    printCentered("JACKPOT", sh() - 18, 2, accent, rgb565a(T.panel));
  } else if (g_phase == PH_WIN) {
    snprintf(foot, sizeof(foot), "+%d  %d", g_lastWin, g_credits);
    printCentered(foot, sh() - 18, 2, COL_WIN, rgb565a(T.panel));
  } else if (g_phase == PH_BROKE) {
    printCentered("INSERT COIN", sh() - 18, 1, COL_LOSE, rgb565a(T.panel));
  } else if (g_phase == PH_LOSE) {
    snprintf(foot, sizeof(foot), "CREDITS %d", g_credits);
    printCentered(foot, sh() - 18, 2, COL_LOSE, rgb565a(T.panel));
  } else {
    snprintf(foot, sizeof(foot), "CREDITS %d", g_credits);
    printCentered(foot, sh() - 18, 2, ink, rgb565a(T.panel));
  }
}

static void drawCabinet(const Theme &T) {
  g->fillScreen(rgb565a(T.bg));
  drawChrome(T);
}

static void drawOverlay() {
  const Theme &T = THEMES[g_theme];
  if (g_phase == PH_JACKPOT) {
    const uint16_t gold = rgb565a(T.gold);
    const uint16_t panel = rgb565a(T.panel);
    fillPill(46, 62, 228, 40, panel);
    printCentered("JACKPOT", 70, 3, gold, panel);
  } else if (g_banner[0] && millis() < g_bannerUntil) {
    const uint16_t panel = rgb565a(T.panel);
    uint16_t fg = rgb565a(T.accent);
    if (g_phase == PH_WIN) fg = COL_WIN;
    else if (g_phase == PH_LOSE || g_phase == PH_BROKE) fg = COL_LOSE;
    const int n = (int)strlen(g_banner);
    const int size = (n > 22) ? 1 : 2;
    int pw = n * 6 * size + 24;
    if (pw < 140) pw = 140;
    if (pw > 308) pw = 308;
    const int ph = (size == 2) ? 28 : 22;
    const int py = (size == 2) ? 68 : 72;
    fillPill((sw() - pw) / 2, py, pw, ph, panel);
    printCentered(g_banner, py + (size == 2 ? 6 : 5), size, fg, panel);
  }
}

static const char *nextWinLine() {
  const char *s = WIN_LINES[g_winI];
  g_winI = (uint8_t)((g_winI + 1) % N_WIN_LINES);
  return s;
}

static const char *nextLoseLine() {
  const char *s = LOSE_LINES[g_loseI];
  g_loseI = (uint8_t)((g_loseI + 1) % N_LOSE_LINES);
  return s;
}

static void drawFrame() {
  const Theme &T = THEMES[g_theme];
  drawCabinet(T);
  for (int i = 0; i < N_REEL; i++) drawReel(i, T);
  maskAroundReels(T);
  drawReelFrames(T);
  drawChrome(T);
  if (g_phase == PH_JACKPOT) drawCoins();
  drawOverlay();
  if (fb) fb->flush();
}

static void finishSpin(uint32_t now) {
  const uint8_t a = g_stop[0], b = g_stop[1], c = g_stop[2];
  const bool triple = (a == b && b == c);
  const bool pair = (a == b || b == c || a == c);
  if (triple) {
    g_lastWin = (a == SYM_SEVEN) ? SEVEN_PAY : TRIPLE_PAY;
    g_credits += g_lastWin;
    g_phase = PH_JACKPOT;
    g_phaseUntil = now + 3200;
    spawnCoins();
    g_banner = "";
    Serial.printf("JACKPOT %s %s %s  +%d  credits=%d\n", SYM_NAME[a], SYM_NAME[b], SYM_NAME[c],
                  g_lastWin, g_credits);
  } else if (pair) {
    g_lastWin = PAIR_PAY;
    g_credits += g_lastWin;
    g_phase = PH_WIN;
    g_phaseUntil = now + 1600;
    g_banner = nextWinLine();
    g_bannerUntil = now + 1500;
    Serial.printf("win %s %s %s  +%d  credits=%d\n", SYM_NAME[a], SYM_NAME[b], SYM_NAME[c],
                  g_lastWin, g_credits);
  } else {
    g_lastWin = 0;
    g_phase = PH_LOSE;
    g_phaseUntil = now + 1600;
    g_banner = nextLoseLine();
    g_bannerUntil = now + 1500;
    Serial.printf("miss %s %s %s  credits=%d\n", SYM_NAME[a], SYM_NAME[b], SYM_NAME[c], g_credits);
  }
  persist();
}

static void pull() {
  const uint32_t now = millis();
  if (g_phase == PH_SPIN) return;
  if (g_credits < SPIN_COST) {
    g_phase = PH_BROKE;
    g_phaseUntil = now + 1200;
    g_banner = "BROKE";
    g_bannerUntil = now + 1100;
    Serial.println(F("broke — type credits or wait, then spin"));
    return;
  }
  g_credits -= SPIN_COST;
  persist();
  g_phase = PH_SPIN;
  g_banner = "";
  g_coinN = 0;
  for (int i = 0; i < N_REEL; i++) {
    g_stop[i] = (uint8_t)random(0, N_SYM);
    g_start[i] = g_pos[i];
    g_end[i] = forwardTo(g_pos[i], g_stop[i], 7 + i * 3 + (int)random(0, 3));
    g_t0[i] = now;
    g_dur[i] = 980 + i * 400 + (uint32_t)random(0, 80);
    g_done[i] = false;
  }
  Serial.printf("spin → %s %s %s  credits=%d\n", SYM_NAME[g_stop[0]], SYM_NAME[g_stop[1]],
                SYM_NAME[g_stop[2]], g_credits);
}

static void forceJackpot() {
  if (g_phase == PH_SPIN) return;
  g_stop[0] = g_stop[1] = g_stop[2] = SYM_SEVEN;
  const uint32_t now = millis();
  g_phase = PH_SPIN;
  for (int i = 0; i < N_REEL; i++) {
    g_start[i] = g_pos[i];
    g_end[i] = forwardTo(g_pos[i], SYM_SEVEN, 6 + i * 2);
    g_t0[i] = now;
    g_dur[i] = 900 + i * 360;
    g_done[i] = false;
  }
  Serial.println(F("forced 7-7-7"));
}

static void cycleTheme() {
  g_theme = (ThemeId)((g_theme + 1) % TH_COUNT);
  persist();
  Serial.printf("theme %s\n", THEMES[g_theme].id);
}

static void applyThemeName(const String &name) {
  for (uint8_t i = 0; i < TH_COUNT; i++) {
    if (name.equalsIgnoreCase(THEMES[i].id)) {
      g_theme = (ThemeId)i;
      persist();
      Serial.printf("theme %s\n", THEMES[g_theme].id);
      return;
    }
  }
  cycleTheme();
}

static void printHelp() {
  Serial.println(F("lucky-slab — neon slot machine on ESP32-C6-LCD-1.47"));
  Serial.println(F("  spin            pull the lever"));
  Serial.println(F("  theme [vegas|diner|cyber]"));
  Serial.println(F("  credits [n]     show or set credits"));
  Serial.println(F("  jackpot         force 7-7-7 (for filming)"));
  Serial.println(F("  status | help"));
  Serial.println(F("BOOT short = spin. Hold BOOT = next cabinet theme."));
}

static void printStatus() {
  Serial.printf("theme: %s  phase: %u  credits: %d  lastWin: %d\n", THEMES[g_theme].id,
                (unsigned)g_phase, g_credits, g_lastWin);
  Serial.printf("reels: %s %s %s  screen: %dx%d landscape\n", SYM_NAME[(int)wrapSym(g_pos[0])],
                SYM_NAME[(int)wrapSym(g_pos[1])], SYM_NAME[(int)wrapSym(g_pos[2])], sw(), sh());
  Serial.printf("boot: GPIO%d  backlight: %u/255  rgb: GPIO%d\n", C6_BOOT_PIN, C6_BL_PWM, C6_RGB_PIN);
}

static void handleSerialLine(String line) {
  line.trim();
  if (!line.length()) return;
  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 == "help" || cmd == "?") {
    printHelp();
    return;
  }
  if (cmd == "status") {
    printStatus();
    return;
  }
  if (cmd == "spin" || cmd == "pull") {
    pull();
    return;
  }
  if (cmd == "theme") {
    if (arg.length()) applyThemeName(arg);
    else cycleTheme();
    return;
  }
  if (cmd == "credits") {
    if (arg.length()) {
      g_credits = constrain(arg.toInt(), 0, 999);
      persist();
    }
    Serial.printf("credits %d\n", g_credits);
    return;
  }
  if (cmd == "jackpot") {
    forceJackpot();
    return;
  }
  Serial.print(F("unknown: "));
  Serial.println(line);
  Serial.println(F("try: help"));
}

static void pollSerial() {
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      handleSerialLine(g_line);
      g_line = "";
    } else if (g_line.length() < 48) {
      g_line += c;
    }
  }
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const uint32_t now = millis();
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    cycleTheme();
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) pull();
    g_bootDown = false;
  }
}

static void stepSpin(uint32_t now, float dt) {
  (void)dt;
  if (g_phase != PH_SPIN) return;
  bool all = true;
  for (int i = 0; i < N_REEL; i++) {
    if (g_done[i]) {
      g_pos[i] = g_end[i];
      continue;
    }
    const float u = easeOutCubic((now - g_t0[i]) / (float)g_dur[i]);
    g_pos[i] = g_start[i] + (g_end[i] - g_start[i]) * u;
    if (now - g_t0[i] >= g_dur[i]) {
      g_pos[i] = g_end[i];
      g_done[i] = true;
    } else {
      all = false;
    }
  }
  if (all) finishSpin(now);
}

static void stepPhase(uint32_t now, float dt) {
  if (g_phase == PH_JACKPOT) stepCoins(dt);
  if (g_phase == PH_SPIN) return;
  if (g_phase != PH_IDLE && now >= g_phaseUntil) {
    if (g_credits <= 0 && g_phase != PH_BROKE) {
      g_phase = PH_BROKE;
      g_phaseUntil = now + 1600;
    } else {
      g_phase = PH_IDLE;
      g_coinN = 0;
    }
  }
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println();
  Serial.println(F("lucky-slab"));

  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  if (!gfx->begin()) Serial.println(F("LCD init failed"));
  gfx->setRotation(1);
  gfx->fillScreen(0);

  fb = new Arduino_Canvas(FB_W, FB_H, gfx, 0, 0, 0);
  if (!fb || !fb->begin(GFX_SKIP_OUTPUT_BEGIN)) {
    Serial.println(F("frame buffer alloc failed — drawing live"));
    if (fb) {
      delete fb;
      fb = nullptr;
    }
    g = gfx;
  } else {
    g = fb;
  }

  reelFb = new Arduino_Canvas(REEL_IW, REEL_IH, nullptr, 0, 0, 0);
  if (!reelFb || !reelFb->begin(GFX_SKIP_OUTPUT_BEGIN)) {
    Serial.println(F("reel clip buffer alloc failed — masking overflow"));
    if (reelFb) {
      delete reelFb;
      reelFb = nullptr;
    }
  }

  rgb.begin();
  rgb.clear();
  rgb.show();

  prefs.begin("luckyslab", false);
  uint8_t th = prefs.getUChar("theme", TH_VEGAS);
  if (th >= TH_COUNT) th = TH_VEGAS;
  g_theme = (ThemeId)th;
  g_credits = prefs.getShort("credits", START_CREDITS);
  if (g_credits < 0 || g_credits > 999) g_credits = START_CREDITS;

  randomSeed(esp_random());
  for (int i = 0; i < N_REEL; i++) {
    g_pos[i] = (float)random(0, N_SYM);
    g_stop[i] = (uint8_t)((int)g_pos[i] % N_SYM);
    g_end[i] = g_pos[i];
    g_done[i] = true;
  }

  drawFrame();
  printHelp();
  printStatus();
}

void loop() {
  const uint32_t now = millis();
  static uint32_t last = now;
  const float dt = clampf((now - last) / 1000.0f, 0.001f, 0.05f);
  last = now;

  pollBoot();
  pollSerial();
  stepSpin(now, dt);
  stepPhase(now, dt);
  pulseLed(now);

  if (now - g_lastDraw >= 33) {
    g_lastDraw = now;
    drawFrame();
  }
}

Board and housing

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