North Pole Welcome Gate

Table of contents

North Pole Welcome Gate

Abstract

An interactive North Pole Welcome Gate that detects visitors, plays festive voice lines, assigns a Christmas role, and runs a quick clap-check challenge—powered by an ESP32-S3 with LEDs, TFT animations, and SD-card audio.

image



Youtube link: https://youtube.com/shorts/r16g2Re46bs?si=um765N5vXXPB2TgD

Overview

The North Pole Welcome Gate is an interactive Christmas guest check-in system designed to feel like a tiny holiday “installation” rather than just a circuit that turns lights on. I wanted a build that reacts instantly when someone approaches, guides them through a fun check-in sequence, and finishes with a satisfying celebratory moment. The result is a complete experience powered by an ESP32-S3, combining TFT animations, addressable LED effects, and pre-recorded voice lines/music stored on an SD card.

One of the best parts of this project is that the WS2812B LED strip isn’t limited to the gate design. You can attach the strip to almost any structure—an arch, a frame, a signboard, a wall outline, a doorway, even a mini “portal”—and instantly upgrade the look. The same software logic still works, so you can scale the visual impact simply by changing the physical layout.


What It Does (Interaction Flow)

Stage 1 — Idle (Attract Mode)

When no one is nearby, the gate stays “alive” with a gentle idle animation on the TFT and a warm LED animation along the strip. It looks active and inviting even from a distance.

Stage 2 — Detection

When someone approaches, the system detects presence and transitions into a more energetic screen animation. A voice line plays to grab attention and announce the experience.

Stage 3 — Invite to Check In

The screen prompts the visitor to check in, and the LED strip shifts into an inviting animation that clearly communicates “you can interact now.” The visitor presses the push button to proceed.

Stage 4 — Role Assignment

After check-in, the system assigns a festive role (for example: Elf, Reindeer Handler, Toy Engineer, Cookie Inspector). The TFT displays it like a fun “role card” moment, while the LEDs support the reveal with a themed animation.

Stage 5 — Verification Challenge (Clap Game)

To make the gate feel interactive and playful, there’s a quick verification mini-game using a clap sensor. The visitor must clap the required number of times within a short time window while the LEDs act as a visual timing/progress indicator.

Stage 6 — Welcome Celebration

If the challenge is successful, the gate “unlocks” briefly, plays a victory sound/voice line, and runs a full celebratory LED + TFT sequence.


Hardware



imageimage

This build is centered around an ESP32-S3 and a few modules that create a strong “holiday experience” when combined:

  • ESP32-S3 (main controller)

  • WS2812B addressable LED strip (main lighting and animations)

  • TFT display (ILI9341, SPI) for animated visuals

  • Presence sensor for approach detection

  • Clap sensor for the verification mini-game

  • Push button for check-in confirmation

  • DFPlayer Mini + microSD card for voice lines and music

  • Speaker connected to the DFPlayer Mini

  • Two 18650 cells + BMS for battery power

  • Buck converter (step-down) set to 5V to power the system

  • Toggle switch on the side as the main power switch

  • 1000µF capacitor across the 5V rail (to stabilise power during LED/audio peaks)

  • Perfboard (I soldered the ESP32-S3, DFPlayer Mini, buck converter, and power distribution onto a perfboard for a clean and durable build)

The LED strip and audio can cause sudden current spikes, especially during bright animations and loud playback, so the buck converter + 1000µF capacitor helps keep the system stable and prevents random resets.

Pinouts (as per your code)

TFT (ILI9341, SPI)

  • TFT_CS → GPIO 7

  • TFT_DC → GPIO 16

  • TFT_RST → GPIO 18

  • TFT_SCK → GPIO 12

  • TFT_MOSI → GPIO 11

  • TFT_MISO → GPIO 13 (only needed if your TFT module actually uses MISO; many don’t)

WS2812B LED strip

  • DIN (Data) → GPIO 6

  • LED_COUNT = 120

  • +5V → 5V rail from buck

  • GND → common ground

  • 1000µF capacitor across 5V–GND near the LED power input White check mark

ToF sensor (VL53L0X, I2C)

  • SDA → GPIO 41

  • SCL → GPIO 40

  • VCC → 3V3

  • GND → GND

DFPlayer Mini (UART on HardwareSerial(1))

  • ESP32-S3 RX (GPIO 14) ← DFPlayer TX

  • ESP32-S3 TX (GPIO 15) → DFPlayer RX (recommended to put ~1k resistor in series on this line)

  • VCC → 5V

  • GND → GND

  • SPK+/SPK− → speaker

Push Button

  • BTN_PIN → GPIO 4

  • Wired GPIO4 to GND, using INPUT_PULLUP White check mark

Mic (MAX4466)

  • MIC analog OUT → GPIO 2 (ADC)

  • Power the MAX4466 at 3V3 so its output stays within ADC safe range White check mark

  • GND common

To keep it portable and practical, the Welcome Gate is battery-powered:

The two 18650 cells feed into a BMS for safe charging/discharging protection. From the battery output, power goes through a side-mounted toggle switch (the main on/off control), then into a buck converter stepped down to 5V, which powers the ESP32-S3, LED strip, display, and DFPlayer. A 1000µF capacitor is placed on the 5V rail to smooth voltage dips when the LEDs or audio demand higher current.


3D Design & Models
imageimage

The enclosure was designed to make the project feel like a finished “device” instead of an exposed electronics prototype. The main goals were to keep the TFT easy to view, route the LED strip cleanly, hide wiring, and still allow quick access for debugging and maintenance. The casing is assembled using four M3 × 16 mm hex button head cap screws, which keeps everything sturdy while remaining easy to open.

I designed the parts in Fusion 360 and printed them on a Bambu Lab A1 Mini using PLA Basic and PLA Matte. The design is intended to be straightforward to print and assemble, while providing enough internal space for the perfboard electronics, wiring, and speaker. Since the LED strip can be mounted onto almost any structure, the models can also be remixed into different “gate” shapes—taller arches, wider frames, wall-mounted portals, or tabletop mini-gates—without changing the core logic of the project.


Code

The code is written as a stage-based state machine, because it keeps the experience smooth and responsive. Each stage has a clear entry action (set screen, set LED animation, play audio) and a clear condition to move to the next stage. This structure avoids the “laggy” feel that happens when long delays block animations or input checks.

The clap challenge is implemented with timing rules and thresholds to reduce false triggers. The core idea is to detect short amplitude spikes as claps, count them within a defined time window, and provide visual feedback using the LED strip so the visitor understands what is happening in real time.

I have included the full source code for the project so it can be rebuilt and modified easily.

#include <Wire.h>
#include <SPI.h>
#include <math.h>

#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>

#include <HardwareSerial.h>
#include <DFRobotDFPlayerMini.h>

// ===================== PIN MAP =====================
// TFT (ILI9341 SPI)
#define TFT_CS   7
#define TFT_DC   16
#define TFT_RST  18
#define TFT_SCK  12
#define TFT_MOSI 11
#define TFT_MISO 13

Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);

// WS2812B
#define LED_PIN   6
#define LED_COUNT 120
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);

// ToF (VL53L0X I2C)
#define I2C_SDA 41
#define I2C_SCL 40
Adafruit_VL53L0X lox;

// DFPlayer
#define DF_RX 14
#define DF_TX 15
HardwareSerial dfSerial(1);
DFRobotDFPlayerMini dfplayer;

// Button
#define BTN_PIN 4  // GPIO to GND, INPUT_PULLUP

// Mic (MAX4466)
#define MIC_PIN 2  // ADC

// ===================== TUNING =====================
static const int DETECT_MM = 350;
static const int LEAVE_MM  = 500;

static const uint32_t TOF_POLL_MS    = 80;
static const uint32_t LED_FRAME_MS   = 20;
static const uint8_t  LED_BRIGHTNESS = 85;

// Stage timings
static const uint32_t ST2_SCAN_MS         = 5200;   // slower scan
static const uint32_t INVITE_TIMEOUT_MS   = 12000;
static const uint32_t UNLOCK_MS           = 4000;   // keep 4000

// Stage 4 (Role Assignment) — slower + dramatic
static const uint32_t ST4_PHASE_A_MS   = 1200;
static const uint32_t ST4_PHASE_B_MS   = 2600;
static const uint32_t ST4_PHASE_C_MS   = 2600;
static const uint32_t ST4_PHASE_D_MS   = 3400;
static const uint32_t ST4_TOTAL_MS     = ST4_PHASE_A_MS + ST4_PHASE_B_MS + ST4_PHASE_C_MS + ST4_PHASE_D_MS;
static const uint32_t ST4_UI_FRAME_MS  = 70;

// Stage 5 (Clap Rush)
static const uint32_t ST5_INTRO_MS     = 1400;
static const uint32_t ST5_BASE_MS      = 5000;
static const uint32_t ST5_PERCLAP_MS   = 1500;
static const uint32_t ST5_UI_FRAME_MS  = 70;

// Stage 6 (Portal Open + Celebration) — longer + cinematic
static const uint32_t ST6_RING_MS      = 4200;
static const uint32_t ST6_CELE_MS      = 6200;
static const uint32_t ST6_TOTAL_MS     = ST6_RING_MS + ST6_CELE_MS;
static const uint32_t ST6_UI_FRAME_MS  = 60;

// Clap detection (non-blocking sampler)
// NOTE: Your test logs showed quiet P2P ~60-120 and claps ~300-450.
// Start around 200-300.
static const int  CLAP_THRESHOLD       = 250;
static const int  SAMPLE_WINDOW_MS     = 35;
static const uint32_t CLAP_DEBOUNCE_MS = 220;

// Detect confirm
static const uint8_t  DETECT_CONFIRM_READS = 3;

// ===================== DFPLAYER TRACK MAP =====================
// Make sure you have these files in /MP3/ on the DFPlayer SD card as 0001.mp3 etc.
static const int TRK_IDLE        = 1;   // 0001.mp3
static const int TRK_SCAN        = 2;   // 0002.mp3
static const int TRK_UNLOCK      = 3;   // 0003.mp3
static const int TRK_ROLE_ASSIGN = 4;   // 0004.mp3
static const int TRK_STAMP_SFX   = 5;   // 0005.mp3
static const int TRK_ROBOT_ID    = 6;   // 0006.mp3

static const int TRK_VERIFY_START   = 7;   // 0007.mp3
static const int TRK_VERIFY_PROMPT  = 8;   // 0008.mp3
static const int TRK_VERIFY_SUCCESS = 9;   // 0009.mp3


static const int TRK_VERIFY_FAIL    = 13;  // 0013.mp3 (recommended)

// Role voice base
static const int TRK_ROLE_BASE   = 10;  // 0010.. role lines

// Stage 6 audio (pick your own numbers)
static const int TRK_GATE_OPEN   = 14;  // 0014.mp3
static const int TRK_GATE_WHOOSH = 15;  // 0015.mp3
static const int TRK_GATE_JINGLE = 16;  // 0016.mp3

// ===================== STAGE 1 LED MODE =====================
enum IdleLedMode { LED_COMET_SWEEP, LED_RADAR_PING };
static const IdleLedMode IDLE_LED_MODE = LED_COMET_SWEEP;

// ===================== COLORS =====================
// Idle Comet (purple bg + gold comet)
static const uint8_t IDLE_COMET_BG_R = 8;
static const uint8_t IDLE_COMET_BG_G = 0;
static const uint8_t IDLE_COMET_BG_B = 14;

static const uint8_t IDLE_COMET_HEAD_R = 255;
static const uint8_t IDLE_COMET_HEAD_G = 170;
static const uint8_t IDLE_COMET_HEAD_B = 40;

static const uint8_t IDLE_COMET_SPARK_R = 255;
static const uint8_t IDLE_COMET_SPARK_G = 80;
static const uint8_t IDLE_COMET_SPARK_B = 10;

// Idle Radar (green)
static const uint8_t IDLE_RADAR_BG_R = 0;
static const uint8_t IDLE_RADAR_BG_G = 6;
static const uint8_t IDLE_RADAR_BG_B = 2;

static const uint8_t IDLE_RADAR_PING_R = 30;
static const uint8_t IDLE_RADAR_PING_G = 255;
static const uint8_t IDLE_RADAR_PING_B = 70;

// Stage 2 Scan (warm gold)
static const uint8_t SCAN_BG_R = 8;
static const uint8_t SCAN_BG_G = 4;
static const uint8_t SCAN_BG_B = 0;

static const uint8_t SCAN_HEAD_R = 255;
static const uint8_t SCAN_HEAD_G = 190;
static const uint8_t SCAN_HEAD_B = 40;

// Stage 3 Invite (Candy Cane Drift)
static const uint8_t INV_BG_R = 2;
static const uint8_t INV_BG_G = 2;
static const uint8_t INV_BG_B = 2;

static const uint8_t INV_RED_R = 255;
static const uint8_t INV_RED_G = 30;
static const uint8_t INV_RED_B = 20;

static const uint8_t INV_GRN_R = 25;
static const uint8_t INV_GRN_G = 255;
static const uint8_t INV_GRN_B = 60;

// Unlock effect (warm)
static const uint8_t UNLOCK_BG_R = 2;
static const uint8_t UNLOCK_BG_G = 6;
static const uint8_t UNLOCK_BG_B = 2;

static const uint8_t UNLOCK_FILL_R = 255;
static const uint8_t UNLOCK_FILL_G = 220;
static const uint8_t UNLOCK_FILL_B = 120;

// ===================== ROLES (3) =====================
struct RoleDef {
  const char* name;
  const char* access;
  uint16_t accent;
  int voiceTrack;
};

RoleDef ROLES[] = {
  { "Head Elf",         "GOLD",  ILI9341_YELLOW, TRK_ROLE_BASE + 0 }, // 0010
  { "Sleigh Engineer",  "CYAN",  ILI9341_CYAN,   TRK_ROLE_BASE + 1 }, // 0011
  { "Cookie Inspector", "AMBER", ILI9341_ORANGE, TRK_ROLE_BASE + 2 }  // 0012
};
static const int ROLE_COUNT = sizeof(ROLES) / sizeof(ROLES[0]);

// ===================== STATE MACHINE =====================
enum State {
  ST_IDLE,
  ST_DETECT,
  ST_INVITE,
  ST_UNLOCK,
  ST_ROLE4,
  ST_VERIFY5,
  ST_PASS,
  ST_GATE6,
  ST_FAIL
};

State state = ST_IDLE;
uint32_t stateStart = 0;

uint32_t lastLedFrame = 0;
uint32_t lastToF = 0;
int dist = -1;

uint8_t nearCount = 0;

// Stage flags
bool idleUiInitDone = false;
bool idleMusicStarted = false;

bool st2UiInitDone = false;
bool st2AudioStarted = false;

bool inviteUiInitDone = false;
uint32_t lastInviteUiFrame = 0;

bool unlockUiInitDone = false;
bool unlockAudioStarted = false;

// Stage 4
bool st4UiInitDone = false;
bool st4StampPlayed = false;
bool st4RobotPlayed = false;
bool st4RoleVoicePlayed = false;
bool st4CardLockedDrawn = false;
uint32_t st4LastUiFrame = 0;

int currentRole = 0;
int staffId = 0;

// Stage 4 LCD sparkles
static const uint8_t ST4_SPARKS = 12;
int16_t spX[ST4_SPARKS], spY[ST4_SPARKS];
uint16_t spC[ST4_SPARKS];

// Stage 5 runtime (Clap Rush)
bool st5UiInitDone = false;
bool st5AudioStartPlayed = false;

uint8_t st5RequiredClaps = 1;
uint8_t st5GotClaps = 0;
uint32_t st5WindowMs = 7000;
uint32_t st5StartMs = 0;
uint32_t st5LastUiFrame = 0;

bool st5RetryUsed = false;
uint32_t st5ClapFlashUntil = 0;
bool st5FailedFlash = false;

// Clap sampler (non-blocking)
uint32_t micWinStart = 0;
int micMinV = 4095;
int micMaxV = 0;
uint32_t lastClapMs = 0;
bool clapEvent = false;

// Stage 6
bool st6UiInitDone = false;
bool st6GateSfxPlayed = false;
bool st6WhooshPlayed = false;
bool st6JinglePlayed = false;
uint32_t st6LastUiFrame = 0;

// Stage 6 LCD ring geometry
static const int ST6_RING_CX = 160;
static const int ST6_RING_CY = 140;
static const int ST6_RING_R  = 52;
static const uint8_t ST6_RING_DOTS = 24;
int16_t st6dx[ST6_RING_DOTS], st6dy[ST6_RING_DOTS];
int8_t  st6LastDot = -1;

// ---- Stage 6 LCD confetti (prevents overwriting text) ----
static const uint8_t ST6_CONFETTI = 22;
int16_t st6cx[ST6_CONFETTI];
int16_t st6cy[ST6_CONFETTI];
uint16_t st6cc[ST6_CONFETTI];
bool st6CeleStaticDrawn = false;

// ===================== HELPERS =====================
uint32_t msInState() { return millis() - stateStart; }

static float easeInOut(float x) {
  if (x < 0) x = 0;
  if (x > 1) x = 1;
  return x * x * (3.0f - 2.0f * x);
}

void playTrack(int t) { dfplayer.playMp3Folder(t); }

void setState(State s) {
  state = s;
  stateStart = millis();

  if (s == ST_IDLE) {
    idleUiInitDone = false;
    idleMusicStarted = false;
    nearCount = 0;
  } else if (s == ST_DETECT) {
    st2UiInitDone = false;
    st2AudioStarted = false;
  } else if (s == ST_INVITE) {
    inviteUiInitDone = false;
    lastInviteUiFrame = 0;
  } else if (s == ST_UNLOCK) {
    unlockUiInitDone = false;
    unlockAudioStarted = false;
  } else if (s == ST_ROLE4) {
    st4UiInitDone = false;
    st4StampPlayed = false;
    st4RobotPlayed = false;
    st4RoleVoicePlayed = false;
    st4CardLockedDrawn = false;
    st4LastUiFrame = 0;
  } else if (s == ST_VERIFY5) {
    st5UiInitDone = false;
    st5AudioStartPlayed = false;
    st5GotClaps = 0;
    st5StartMs = 0;
    st5LastUiFrame = 0;
    st5ClapFlashUntil = 0;
    st5FailedFlash = false;
  } else if (s == ST_GATE6) {
    st6UiInitDone = false;
    st6GateSfxPlayed = false;
    st6WhooshPlayed = false;
    st6JinglePlayed = false;
    st6LastUiFrame = 0;
    st6LastDot = -1;

    st6CeleStaticDrawn = false;
    for (int i = 0; i < ST6_CONFETTI; i++) {
      st6cx[i] = -1;
      st6cy[i] = -1;
      st6cc[i] = ILI9341_BLACK;
    }
  }
}

int readDistanceMM() {
  VL53L0X_RangingMeasurementData_t m;
  lox.rangingTest(&m, false);
  if (m.RangeStatus == 4) return -1;
  return (int)m.RangeMilliMeter;
}

bool buttonPressed() {
  static uint8_t lastReading = HIGH;
  static uint8_t stableState = HIGH;
  static uint32_t lastChange = 0;

  const uint32_t DEB_MS = 35;
  uint8_t reading = digitalRead(BTN_PIN);
  uint32_t now = millis();

  if (reading != lastReading) {
    lastReading = reading;
    lastChange = now;
  }
  if ((now - lastChange) > DEB_MS && reading != stableState) {
    stableState = reading;
    if (stableState == LOW) return true;
  }
  return false;
}

// --- Clap sampling (non-blocking) ---
void micInitSampler() {
  micWinStart = millis();
  micMinV = 4095;
  micMaxV = 0;
  lastClapMs = 0;
  clapEvent = false;
  (void)analogRead(MIC_PIN);
}

void micTickSampler(uint32_t now) {
  clapEvent = false;

  int v = analogRead(MIC_PIN);
  if (v < micMinV) micMinV = v;
  if (v > micMaxV) micMaxV = v;

  if ((now - micWinStart) >= (uint32_t)SAMPLE_WINDOW_MS) {
    int p2p = micMaxV - micMinV;
    micWinStart = now;
    micMinV = 4095;
    micMaxV = 0;

    if (p2p > CLAP_THRESHOLD && (now - lastClapMs) > CLAP_DEBOUNCE_MS) {
      lastClapMs = now;
      clapEvent = true;
    }
  }
}

// ===================== TFT UI HELPERS =====================
void tftHeader(const char* title) {
  tft.fillScreen(ILI9341_BLACK);
  tft.setTextWrap(false);

  tft.setTextSize(2);
  tft.setTextColor(ILI9341_CYAN);
  tft.setCursor(10, 10);
  tft.print(title);
  tft.drawFastHLine(10, 35, tft.width() - 20, ILI9341_DARKGREY);
}

void tftCenterText(int y, const char* txt, uint8_t size, uint16_t color) {
  tft.setTextWrap(false);
  tft.setTextSize(size);
  tft.setTextColor(color);

  int16_t x1, y1; uint16_t w, h;
  tft.getTextBounds((char*)txt, 0, 0, &x1, &y1, &w, &h);
  int cx = (tft.width() - (int)w) / 2;
  tft.setCursor(cx, y);
  tft.print(txt);
}

void tftTiny(int x, int y, const char* txt, uint16_t color) {
  tft.setTextWrap(false);
  tft.setTextSize(1);
  tft.setTextColor(color);
  tft.setCursor(x, y);
  tft.print(txt);
}

// ===================== LED HELPERS =====================
uint32_t rgb(uint8_t r, uint8_t g, uint8_t b) { return strip.Color(r, g, b); }

uint8_t scale8(uint8_t v, uint8_t scale) {
  return (uint16_t)v * (uint16_t)scale / 255;
}

// ===================== LED ANIMATIONS =====================
void ledsIdleCometSweep(uint32_t now) {
  for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, rgb(IDLE_COMET_BG_R, IDLE_COMET_BG_G, IDLE_COMET_BG_B));

  const uint16_t speedMs = 18;
  int head = (now / speedMs) % LED_COUNT;

  const int tail = 18;
  for (int k = 0; k <= tail; k++) {
    int idx = head - k;
    if (idx < 0) idx += LED_COUNT;

    uint8_t bright = (uint8_t)(255 - (k * (255 / (tail + 1))));
    strip.setPixelColor(idx, rgb(scale8(IDLE_COMET_HEAD_R, bright), scale8(IDLE_COMET_HEAD_G, bright), scale8(IDLE_COMET_HEAD_B, bright)));
  }

  if ((now % 320) < 20) {
    int s = (head + random(6)) % LED_COUNT;
    strip.setPixelColor(s, rgb(IDLE_COMET_SPARK_R, IDLE_COMET_SPARK_G, IDLE_COMET_SPARK_B));
  }
  strip.show();
}

void ledsIdleRadarPing(uint32_t now) {
  for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, rgb(IDLE_RADAR_BG_R, IDLE_RADAR_BG_G, IDLE_RADAR_BG_B));

  const uint32_t cycle = 1400;
  float p = (float)(now % cycle) / (float)cycle;
  int radius = (int)(p * (LED_COUNT / 2));

  for (int glow = 0; glow < 8; glow++) {
    uint8_t bright = (uint8_t)(220 - glow * 26);
    uint8_t rr = scale8(IDLE_RADAR_PING_R, bright);
    uint8_t gg = scale8(IDLE_RADAR_PING_G, bright);
    uint8_t bb = scale8(IDLE_RADAR_PING_B, bright);

    int a = radius + glow;
    int bpos = radius - glow;

    int idx1 = (a) % LED_COUNT;
    int idx2 = (LED_COUNT - a) % LED_COUNT;
    int idx3 = (bpos + LED_COUNT) % LED_COUNT;
    int idx4 = (LED_COUNT - bpos + LED_COUNT) % LED_COUNT;

    strip.setPixelColor(idx1, rgb(rr, gg, bb));
    strip.setPixelColor(idx2, rgb(rr, gg, bb));
    strip.setPixelColor(idx3, rgb(rr, gg, bb));
    strip.setPixelColor(idx4, rgb(rr, gg, bb));
  }
  strip.show();
}

void ledsScannerSweep(uint32_t now) {
  for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, rgb(SCAN_BG_R, SCAN_BG_G, SCAN_BG_B));

  uint32_t e = msInState();
  float phase = (float)e / (float)ST2_SCAN_MS;
  if (phase > 1.0f) phase = 1.0f;

  const float sweeps = 4.0f;
  float f = phase * sweeps;
  float frac = f - floorf(f);
  int head = (int)(frac * (LED_COUNT - 1));

  const int tail = 26;
  for (int k = 0; k <= tail; k++) {
    int idx = head - k;
    if (idx < 0) idx += LED_COUNT;

    uint8_t bright = (uint8_t)(255 - (k * (255 / (tail + 1))));
    strip.setPixelColor(idx, rgb(scale8(SCAN_HEAD_R, bright), scale8(SCAN_HEAD_G, bright), scale8(SCAN_HEAD_B, bright)));
  }
  strip.show();
}

void ledsInviteCandyGate(uint32_t now) {
  float phase = (now % 1400) / 1400.0f;
  float breath = 0.35f + 0.65f * (0.5f - 0.5f * cosf(phase * 2.0f * 3.14159f));
  uint8_t k = (uint8_t)(breath * 255);

  int offset = (now / 40) % LED_COUNT;
  const int stripe = 6;

  for (int i = 0; i < LED_COUNT; i++) {
    int p = (i + offset) % (stripe * 2);
    bool isRed = (p < stripe);

    uint8_t r, g, b;
    if (isRed) { r = scale8(INV_RED_R, k); g = scale8(INV_RED_G, k); b = scale8(INV_RED_B, k); }
    else       { r = scale8(INV_GRN_R, k); g = scale8(INV_GRN_G, k); b = scale8(INV_GRN_B, k); }

    r = (uint8_t)((r * 8 + INV_BG_R * 2) / 10);
    g = (uint8_t)((g * 8 + INV_BG_G * 2) / 10);
    b = (uint8_t)((b * 8 + INV_BG_B * 2) / 10);

    strip.setPixelColor(i, rgb(r, g, b));
  }
  if ((now % 260) < 18) strip.setPixelColor(random(LED_COUNT), rgb(255, 255, 255));
  strip.show();
}

void ledsDoorUnlock(uint32_t now) {
  float p = (float)msInState() / (float)UNLOCK_MS;
  if (p > 1.0f) p = 1.0f;
  float pe = easeInOut(p);

  for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, rgb(UNLOCK_BG_R, UNLOCK_BG_G, UNLOCK_BG_B));

  int half = LED_COUNT / 2;
  int lit = (int)(pe * half);

  int leftCenter  = half - 1;
  int rightCenter = half;

  for (int i = 0; i < lit; i++) {
    int L = leftCenter - i;
    int R = rightCenter + i;

    uint8_t bright = 255;
    if (i > lit - 12) {
      int tail = (lit - 1) - i;
      if (tail < 0) tail = 0;
      bright = (uint8_t)(90 + tail * 14);
    }

    uint8_t r = scale8(UNLOCK_FILL_R, bright);
    uint8_t g = scale8(UNLOCK_FILL_G, bright);
    uint8_t b = scale8(UNLOCK_FILL_B, bright);

    if (L >= 0)        strip.setPixelColor(L, rgb(r, g, b));
    if (R < LED_COUNT) strip.setPixelColor(R, rgb(r, g, b));
  }

  if ((now % 260) < 18) strip.setPixelColor(random(LED_COUNT), rgb(255, 255, 255));
  strip.show();
}

// ===== Stage 4 palette + animation =====
void rolePalette(uint8_t roleIdx, uint8_t &pR, uint8_t &pG, uint8_t &pB,
                 uint8_t &sR, uint8_t &sG, uint8_t &sB) {
  if (roleIdx == 0) { pR=40;  pG=255; pB=90;  sR=255; sG=200; sB=60;  return; } // green + gold
  if (roleIdx == 1) { pR=60;  pG=220; pB=255; sR=200; sG=80;  sB=255; return; } // cyan + purple
  pR=255; pG=150; pB=50;  sR=255; sG=50; sB=40; // amber + red
}

void ledsRoleAssign(uint32_t now) {
  uint32_t e = msInState();

  uint32_t tBstart = ST4_PHASE_A_MS;
  uint32_t tCstart = ST4_PHASE_A_MS + ST4_PHASE_B_MS;
  uint32_t tDstart = ST4_PHASE_A_MS + ST4_PHASE_B_MS + ST4_PHASE_C_MS;

  uint8_t pR,pG,pB,sR,sG,sB;
  rolePalette((uint8_t)currentRole, pR,pG,pB, sR,sG,sB);

  // Phase A: GOLD stream
  if (e < ST4_PHASE_A_MS) {
    strip.clear();
    int head = (now / 9) % LED_COUNT;
    for (int k = 0; k < 30; k++) {
      int idx = head - k; if (idx < 0) idx += LED_COUNT;
      uint8_t bright = (uint8_t)(255 - k * 8);
      strip.setPixelColor(idx, rgb(scale8(255, bright), scale8(190, bright), scale8(40, bright)));
    }
    strip.show();
    return;
  }

  // Phase B swirl
  if (e < tCstart) {
    float pp = (float)(e - tBstart) / (float)ST4_PHASE_B_MS;
    float pe = easeInOut(pp);

    int swirlOffset = (now / 18) % LED_COUNT;
    for (int i = 0; i < LED_COUNT; i++) {
      float w = sinf((i + swirlOffset) * 0.18f) * 0.5f + 0.5f;
      uint8_t r = (uint8_t)(pR * w + sR * (1.0f - w));
      uint8_t g = (uint8_t)(pG * w + sG * (1.0f - w));
      uint8_t b = (uint8_t)(pB * w + sB * (1.0f - w));

      uint8_t k = (uint8_t)(70 + 185 * pe);
      strip.setPixelColor(i, rgb(scale8(r, k), scale8(g, k), scale8(b, k)));
    }
    strip.setPixelColor((int)(pe * (LED_COUNT / 2)) % LED_COUNT, rgb(255,255,255));
    strip.show();
    return;
  }

  // Phase C halo
  if (e < tDstart) {
    int orbit = (now / 14) % LED_COUNT;
    for (int i = 0; i < LED_COUNT; i++) {
      float w = sinf((i + (now / 22)) * 0.22f) * 0.5f + 0.5f;
      uint8_t r = (uint8_t)(pR * (0.55f + 0.45f * w));
      uint8_t g = (uint8_t)(pG * (0.55f + 0.45f * w));
      uint8_t b = (uint8_t)(pB * (0.55f + 0.45f * w));
      strip.setPixelColor(i, rgb(r, g, b));
    }
    strip.setPixelColor(orbit, rgb(255,255,255));
    strip.show();
    return;
  }

  // Phase D aurora + ribbon
  float ph = (now % 1400) / 1400.0f;
  float breath = 0.30f + 0.70f * (0.5f - 0.5f * cosf(ph * 2.0f * 3.14159f));
  uint8_t kb = (uint8_t)(breath * 255);

  const uint8_t goldR = 255, goldG = 185, goldB = 55;
  float time = (float)now * 0.0040f;
  for (int i = 0; i < LED_COUNT; i++) {
    float w = sinf(i * 0.16f + time) * 0.5f + 0.5f;
    uint8_t r = (uint8_t)(goldR * (0.55f + 0.45f * w));
    uint8_t g = (uint8_t)(goldG * (0.55f + 0.45f * w));
    uint8_t b = (uint8_t)(goldB * (0.55f + 0.45f * w));
    strip.setPixelColor(i, rgb(scale8(r, kb), scale8(g, kb), scale8(b, kb)));
  }

  int head = (now / 10) % LED_COUNT;
  const int tail = 22;
  for (int k = 0; k <= tail; k++) {
    int idx = head - k; if (idx < 0) idx += LED_COUNT;
    uint8_t bright = (uint8_t)(255 - (k * (255 / (tail + 1))));
    strip.setPixelColor(idx, rgb(scale8(pR, bright), scale8(pG, bright), scale8(pB, bright)));
  }

  if ((now % 220) < 18) strip.setPixelColor(random(LED_COUNT), rgb(255,255,255));
  strip.show();
}

// ===== Stage 5 LED: Clap Rush progress (role + gold meter) =====
void ledsVerifyClapRush(uint32_t now) {
  uint8_t pR,pG,pB,sR,sG,sB;
  rolePalette((uint8_t)currentRole, pR,pG,pB, sR,sG,sB);

  float ph = (now % 1300) / 1300.0f;
  float breath = 0.35f + 0.65f * (0.5f - 0.5f * cosf(ph * 2.0f * 3.14159f));
  uint8_t k = (uint8_t)(breath * 255);

  for (int i = 0; i < LED_COUNT; i++) {
    uint8_t r = (uint8_t)((pR * 7 + 15) / 8);
    uint8_t g = (uint8_t)((pG * 7 + 15) / 8);
    uint8_t b = (uint8_t)((pB * 7 + 10) / 8);
    strip.setPixelColor(i, rgb(scale8(r, k), scale8(g, k), scale8(b, k)));
  }

  if (st5StartMs != 0) {
    uint32_t e = (now > st5StartMs) ? (now - st5StartMs) : 0;
    float p = (st5WindowMs == 0) ? 1.0f : (float)e / (float)st5WindowMs;
    if (p < 0) p = 0;
    if (p > 1) p = 1;

    int lit = (int)(p * LED_COUNT);
    for (int i = 0; i < lit; i++) strip.setPixelColor(i, rgb(255, 190, 55));
    if (lit >= 0 && lit < LED_COUNT) strip.setPixelColor(lit, rgb(255,255,255));
  }

  if (now < st5ClapFlashUntil) {
    for (int s = 0; s < 16; s++) strip.setPixelColor(random(LED_COUNT), rgb(255,255,255));
    for (int s = 0; s < 10; s++) strip.setPixelColor(random(LED_COUNT), rgb(255,200,60));
  }

  if (st5FailedFlash && ((now / 120) % 2 == 0)) {
    for (int i = 0; i < LED_COUNT; i += 6) strip.setPixelColor(i, rgb(255,0,0));
  }

  if ((now % 260) < 18) strip.setPixelColor(random(LED_COUNT), rgb(255,255,255));
  strip.show();
}

// ===== Stage 6 LED: Portal ring -> Golden aurora celebration =====
void ledsStage6(uint32_t now) {
  uint32_t e = msInState();

  uint8_t pR,pG,pB,sR,sG,sB;
  rolePalette((uint8_t)currentRole, pR,pG,pB, sR,sG,sB);

  // Phase 1: ring spin (accent + white spark)
  if (e < ST6_RING_MS) {
    // dim base
    for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, rgb(2,2,2));

    int head = (now / 8) % LED_COUNT;
    const int tail = 28;
    for (int k = 0; k <= tail; k++) {
      int idx = head - k; if (idx < 0) idx += LED_COUNT;
      uint8_t bright = (uint8_t)(255 - (k * (255 / (tail + 1))));
      strip.setPixelColor(idx, rgb(scale8(pR, bright), scale8(pG, bright), scale8(pB, bright)));
    }
    if ((now % 220) < 20) strip.setPixelColor((head + random(12)) % LED_COUNT, rgb(255,255,255));
    strip.show();
    return;
  }

  // Phase 2: celebration (gold aurora + accent ribbon) longer
  float ph = (now % 1400) / 1400.0f;
  float breath = 0.30f + 0.70f * (0.5f - 0.5f * cosf(ph * 2.0f * 3.14159f));
  uint8_t kb = (uint8_t)(breath * 255);

  const uint8_t goldR = 255, goldG = 185, goldB = 55;
  float time = (float)now * 0.0043f;
  for (int i = 0; i < LED_COUNT; i++) {
    float w = sinf(i * 0.16f + time) * 0.5f + 0.5f;
    uint8_t r = (uint8_t)(goldR * (0.55f + 0.45f * w));
    uint8_t g = (uint8_t)(goldG * (0.55f + 0.45f * w));
    uint8_t b = (uint8_t)(goldB * (0.55f + 0.45f * w));
    strip.setPixelColor(i, rgb(scale8(r, kb), scale8(g, kb), scale8(b, kb)));
  }

  int head = (now / 10) % LED_COUNT;
  const int tail = 26;
  for (int k = 0; k <= tail; k++) {
    int idx = head - k; if (idx < 0) idx += LED_COUNT;
    uint8_t bright = (uint8_t)(255 - (k * (255 / (tail + 1))));
    strip.setPixelColor(idx, rgb(scale8(pR, bright), scale8(pG, bright), scale8(pB, bright)));
  }

  // classy twinkles
  if ((now % 160) < 20) strip.setPixelColor(random(LED_COUNT), rgb(255,255,255));
  if ((now % 310) < 18) strip.setPixelColor(random(LED_COUNT), rgb(120,220,255));
  strip.show();
}

// ===================== STAGE 1 LCD: Snowfall + Logo =====================
static const uint32_t SNOW_TFT_FRAME_MS = 55;
static const uint8_t  SNOW_FLAKES = 22;
static const int16_t  SNOW_Y_TOP = 110;
static const int16_t  SNOW_Y_BOTTOM = 190;

struct Flake { int16_t x, y; int8_t v; uint16_t c; };
Flake flakes[SNOW_FLAKES];
uint32_t lastSnowFrame = 0;

uint16_t snowColorVariant() {
  switch (random(3)) {
    case 0: return ILI9341_WHITE;
    case 1: return ILI9341_LIGHTGREY;
    default: return ILI9341_CYAN;
  }
}

void lcdSnowStatic() {
  tft.fillScreen(ILI9341_BLACK);
  tft.setTextWrap(false);

  tft.setTextSize(2);
  tft.setTextColor(ILI9341_CYAN);
  tft.setCursor(10, 10);
  tft.print("North Pole Portal");
  tft.drawFastHLine(10, 35, tft.width() - 20, ILI9341_DARKGREY);

  tftCenterText(55, "NORTH POLE", 3, ILI9341_WHITE);
  tftCenterText(88, "WELCOME GATE", 2, ILI9341_CYAN);
  tftCenterText(205, "Approach to begin", 2, ILI9341_YELLOW);

  tft.setTextSize(1);
  tft.setTextColor(ILI9341_DARKGREY);
  tft.setCursor(10, 225);
  tft.print("Idle • System UI");
}

void lcdSnowInit() {
  lcdSnowStatic();
  for (uint8_t i = 0; i < SNOW_FLAKES; i++) {
    flakes[i].x = random(0, tft.width());
    flakes[i].y = random(SNOW_Y_TOP, SNOW_Y_BOTTOM);
    flakes[i].v = 1 + random(3);
    flakes[i].c = snowColorVariant();
    tft.drawPixel(flakes[i].x, flakes[i].y, flakes[i].c);
  }
  lastSnowFrame = millis();
}

void lcdSnowTick(uint32_t now) {
  if (now - lastSnowFrame < SNOW_TFT_FRAME_MS) return;
  lastSnowFrame = now;

  for (uint8_t i = 0; i < SNOW_FLAKES; i++) {
    tft.drawPixel(flakes[i].x, flakes[i].y, ILI9341_BLACK);
    flakes[i].y += flakes[i].v;

    if (flakes[i].y > SNOW_Y_BOTTOM) {
      flakes[i].y = SNOW_Y_TOP + random(0, 10);
      flakes[i].x = random(0, tft.width());
      flakes[i].v = 1 + random(3);
      flakes[i].c = snowColorVariant();
    }
    tft.drawPixel(flakes[i].x, flakes[i].y, flakes[i].c);
  }
}

void idleUiInit() {
  lcdSnowInit();
  idleUiInitDone = true;

  if (!idleMusicStarted) {
    playTrack(TRK_IDLE);
    idleMusicStarted = true;
  }
}

void idleUiTick(uint32_t now) { lcdSnowTick(now); }

// ===================== STAGE 2 LCD: Scan UI (warm) =====================
void st2ScanUiInit() {
  tftHeader("North Pole Portal");
  tftCenterText(58, "INITIALIZING...", 3, ILI9341_YELLOW);

  tft.setTextSize(2);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(25, 120);
  tft.print("Please hold still");

  int x = 25, y = 160, w = tft.width() - 50, h = 18;
  tft.drawRect(x, y, w, h, ILI9341_DARKGREY);

  st2UiInitDone = true;
}

void st2ScanUiTick() {
  int x = 25, y = 160, w = tft.width() - 50, h = 18;

  float p = (float)msInState() / (float)ST2_SCAN_MS;
  if (p > 1.0f) p = 1.0f;

  if (msInState() > 900) {
    tft.fillRect(0, 45, tft.width(), 45, ILI9341_BLACK);
    tftCenterText(58, "SCANNING...", 3, ILI9341_YELLOW);
  }

  float pe = easeInOut(p);
  int fillW = (int)((w - 2) * pe);

  tft.fillRect(x + 1, y + 1, w - 2, h - 2, ILI9341_BLACK);
  tft.fillRect(x + 1, y + 1, fillW, h - 2, ILI9341_ORANGE);
}

// ===================== STAGE 3 LCD: Invite =====================
static const uint32_t INVITE_UI_FRAME_MS = 90;
static const int INV_BAR_X = 30;
static const int INV_BAR_Y = 176;
static const int INV_BAR_W = 260;
static const int INV_BAR_H = 14;

void inviteUiInit() {
  tftHeader("North Pole Portal");
  tftCenterText(62, "CHECK IN", 3, ILI9341_YELLOW);
  tftCenterText(112, "Press button", 2, ILI9341_WHITE);

  tft.drawRect(INV_BAR_X, INV_BAR_Y, INV_BAR_W, INV_BAR_H, ILI9341_DARKGREY);
  tftTiny(10, 225, "Invite • Button check-in", ILI9341_DARKGREY);

  inviteUiInitDone = true;
  lastInviteUiFrame = millis();
}

void inviteUiTick(uint32_t now) {
  if (now - lastInviteUiFrame < INVITE_UI_FRAME_MS) return;
  lastInviteUiFrame = now;

  float t = (now % 1400) / 1400.0f;
  float breath = 0.25f + 0.75f * (0.5f - 0.5f * cosf(t * 2.0f * 3.14159f));
  int fillW = (int)((INV_BAR_W - 2) * breath);

  tft.fillRect(INV_BAR_X + 1, INV_BAR_Y + 1, INV_BAR_W - 2, INV_BAR_H - 2, ILI9341_BLACK);
  tft.fillRect(INV_BAR_X + 1, INV_BAR_Y + 1, fillW, INV_BAR_H - 2, ILI9341_GREEN);

  const int cheY = 142;
  const int cheX = 70;
  const int cheW = 180;
  tft.fillRect(cheX, cheY, cheW, 22, ILI9341_BLACK);

  int step = (now / 120) % 6;
  tft.setTextSize(2);
  tft.setTextColor(ILI9341_CYAN);
  tft.setCursor(cheX + step * 6, cheY);
  tft.print(">>>>>>");
}

// ===================== STAGE 3.1 LCD: Unlock =====================
void unlockUiInit() {
  tftHeader("North Pole Portal");
  tftCenterText(70, "UNLOCKING", 3, ILI9341_GREEN);
  tftCenterText(120, "Gate access", 2, ILI9341_WHITE);

  int x = 25, y = 165, w = tft.width() - 50, h = 16;
  tft.drawRect(x, y, w, h, ILI9341_DARKGREY);

  unlockUiInitDone = true;
}

void unlockUiTick() {
  int x = 25, y = 165, w = tft.width() - 50, h = 16;

  float p = (float)msInState() / (float)UNLOCK_MS;
  if (p > 1.0f) p = 1.0f;

  float pe = easeInOut(p);
  int fillW = (int)((w - 2) * pe);

  tft.fillRect(x + 1, y + 1, w - 2, h - 2, ILI9341_BLACK);
  tft.fillRect(x + 1, y + 1, fillW, h - 2, ILI9341_GREEN);
}

// ===================== STAGE 4 LCD: ID Card Reveal + Celebration =====================
void st4InitRoleData() {
  currentRole = random(ROLE_COUNT);
  staffId = 1000 + random(9000); // NP-1000..9999

  for (uint8_t i = 0; i < ST4_SPARKS; i++) {
    spX[i] = -1; spY[i] = -1; spC[i] = ILI9341_BLACK;
  }
}

void st4UiInit() {
  tftHeader("North Pole Portal");
  tftCenterText(58, "ASSIGNING...", 3, ILI9341_CYAN);
  tftCenterText(108, "Generating Staff ID", 2, ILI9341_WHITE);
  tftTiny(10, 225, "Role • ID issuance", ILI9341_DARKGREY);

  st4UiInitDone = true;
  st4LastUiFrame = millis();
}

void drawIdCardAt(int cardX) {
  const RoleDef& R = ROLES[currentRole];

  const int CW = 280;
  const int CH = 140;
  const int CY = 72;

  tft.fillRoundRect(cardX + 3, CY + 3, CW, CH, 12, ILI9341_DARKGREY);
  tft.fillRoundRect(cardX, CY, CW, CH, 12, ILI9341_NAVY);
  tft.drawRoundRect(cardX, CY, CW, CH, 12, R.accent);

  tft.setTextSize(1);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(cardX + 12, CY + 10);
  tft.print("NORTH POLE STAFF ID");

  tft.setTextSize(2);
  tft.setTextColor(R.accent);
  tft.setCursor(cardX + 12, CY + 34);
  tft.print("ROLE:");

  tft.setTextSize(3);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(cardX + 12, CY + 54);
  tft.print(R.name);

  char buf[40];
  tft.setTextSize(2);
  tft.setTextColor(ILI9341_LIGHTGREY);

  sprintf(buf, "ID: NP-%d", staffId);
  tft.setCursor(cardX + 12, CY + 98);
  tft.print(buf);

  sprintf(buf, "ACCESS: %s", R.access);
  tft.setCursor(cardX + 12, CY + 118);
  tft.print(buf);

  int bx = cardX + 210;
  int by = CY + 102;
  for (int i = 0; i < 12; i++) {
    int h = 28;
    int w = (i % 3 == 0) ? 3 : 1;
    tft.fillRect(bx + i * 5, by, w, h, ILI9341_WHITE);
  }
}

void drawAssignedBadge(int cardX, int cardY, bool bright) {
  const int bx = cardX + 186;
  const int by = cardY + 10;
  const int bw = 82;
  const int bh = 22;

  uint16_t border = bright ? ILI9341_GREEN : ILI9341_DARKGREEN;
  uint16_t fill   = bright ? ILI9341_BLACK : ILI9341_NAVY;

  tft.fillRoundRect(bx, by, bw, bh, 6, fill);
  tft.drawRoundRect(bx, by, bw, bh, 6, border);

  tft.setTextSize(1);
  tft.setTextColor(border);
  tft.setCursor(bx + 10, by + 7);
  tft.print("ASSIGNED");
}

uint16_t randomSparkleColor() {
  switch (random(6)) {
    case 0: return ILI9341_WHITE;
    case 1: return ILI9341_YELLOW;
    case 2: return ILI9341_ORANGE;
    case 3: return ILI9341_CYAN;
    case 4: return ILI9341_MAGENTA;
    default:return ILI9341_GREEN;
  }
}

void st4SparklesTick() {
  for (uint8_t i = 0; i < ST4_SPARKS; i++) {
    if (spX[i] >= 0) tft.drawPixel(spX[i], spY[i], ILI9341_BLACK);
  }

  for (uint8_t i = 0; i < ST4_SPARKS; i++) {
    int x = random(5, 315);
    int y = random(55, 220);

    if (x > 25 && x < 295 && y > 78 && y < 206) {
      if (random(100) < 70) {
        x = random(5, 315);
        y = random(55, 220);
      }
    }

    spX[i] = x;
    spY[i] = y;
    spC[i] = randomSparkleColor();
    tft.drawPixel(spX[i], spY[i], spC[i]);
  }
}

void st4WelcomeBanner(uint32_t now) {
  tft.fillRect(0, 42, tft.width(), 26, ILI9341_BLACK);
  uint16_t col = ((now / 240) % 2 == 0) ? ROLES[currentRole].accent : ILI9341_YELLOW;
  tftCenterText(48, "WELCOME TO THE NORTH POLE!", 2, col);
}

void st4UiTick(uint32_t now) {
  if (now - st4LastUiFrame < ST4_UI_FRAME_MS) return;
  st4LastUiFrame = now;

  uint32_t e = msInState();

  if (e < ST4_PHASE_A_MS) {
    int dot = (e / 180) % 4;
    tft.fillRect(0, 140, tft.width(), 28, ILI9341_BLACK);
    tft.setTextSize(3);
    tft.setTextColor(ILI9341_CYAN);
    tft.setCursor(140, 145);
    if (dot == 0) tft.print(".");
    if (dot == 1) tft.print("..");
    if (dot == 2) tft.print("...");
    if (dot == 3) tft.print("....");
    return;
  }

  uint32_t tBstart = ST4_PHASE_A_MS;
  uint32_t tCstart = ST4_PHASE_A_MS + ST4_PHASE_B_MS;
  uint32_t tDstart = ST4_PHASE_A_MS + ST4_PHASE_B_MS + ST4_PHASE_C_MS;

  if (e < tCstart) {
    float p = (float)(e - tBstart) / (float)ST4_PHASE_B_MS;
    float pe = easeInOut(p);

    tft.fillRect(0, 42, tft.width(), 190, ILI9341_BLACK);
    tftCenterText(45, "ROLE ISSUANCE", 2, ILI9341_CYAN);

    int startX = tft.width();
    int endX   = 20;
    int cardX  = startX + (int)((endX - startX) * pe);

    drawIdCardAt(cardX);
    return;
  }

  if (!st4CardLockedDrawn) {
    tft.fillRect(0, 42, tft.width(), 190, ILI9341_BLACK);
    tftCenterText(45, "ROLE ISSUANCE", 2, ILI9341_CYAN);
    drawIdCardAt(20);
    st4CardLockedDrawn = true;
  }

  bool bright = ((now / 260) % 2 == 0);
  drawAssignedBadge(20, 72, bright);

  if (e < tDstart) {
    tft.fillRect(0, 214, tft.width(), 16, ILI9341_BLACK);
    tftTiny(12, 218, "Status: ID issued  |  Proceeding soon...", ILI9341_DARKGREY);
    return;
  }

  st4WelcomeBanner(now);
  st4SparklesTick();

  tft.fillRect(0, 214, tft.width(), 16, ILI9341_BLACK);
  tftTiny(12, 218, "Proceed to verification ritual...", ILI9341_LIGHTGREY);
}

// ===================== STAGE 5 LCD: Clap Rush =====================
void st5UiInit() {
  tftHeader("North Pole Portal");
  tftCenterText(52, "VERIFICATION", 3, ILI9341_YELLOW);
  tftCenterText(90, "Clap Rush", 2, ILI9341_WHITE);
  tftTiny(10, 225, "Stage 5 • 1 retry allowed", ILI9341_DARKGREY);
  st5LastUiFrame = millis();
}

void st5UiTick(uint32_t now) {
  if (now - st5LastUiFrame < ST5_UI_FRAME_MS) return;
  st5LastUiFrame = now;

  uint32_t e = msInState();

  if (e < ST5_INTRO_MS) {
    tft.fillRect(0, 120, tft.width(), 90, ILI9341_BLACK);
    uint16_t col = ((now / 260) % 2 == 0) ? ILI9341_YELLOW : ILI9341_ORANGE;
    tftCenterText(130, "Prepare...", 3, col);
    return;
  }

  if (st5StartMs == 0) return;

  uint32_t elapsed = now - st5StartMs;
  uint32_t left = (elapsed >= st5WindowMs) ? 0 : (st5WindowMs - elapsed);
  uint32_t leftSec = (left + 999) / 1000;

  tft.fillRect(0, 110, tft.width(), 105, ILI9341_BLACK);

  char buf[40];
  sprintf(buf, "CLAP %d TIME%s", (int)st5RequiredClaps, (st5RequiredClaps == 1) ? "" : "S");
  tftCenterText(112, buf, 3, ILI9341_CYAN);

  sprintf(buf, "Claps: %d / %d", (int)st5GotClaps, (int)st5RequiredClaps);
  tftCenterText(148, buf, 2, ILI9341_WHITE);

  sprintf(buf, "Time left: %ds", (int)leftSec);
  tftCenterText(172, buf, 2, ILI9341_YELLOW);

  int x = 30, y = 198, w = 260, h = 14;
  tft.drawRect(x, y, w, h, ILI9341_DARKGREY);

  float p = (st5WindowMs == 0) ? 1.0f : (float)elapsed / (float)st5WindowMs;
  if (p < 0) p = 0;
  if (p > 1) p = 1;
  int fillW = (int)((w - 2) * p);

  tft.fillRect(x + 1, y + 1, w - 2, h - 2, ILI9341_BLACK);
  tft.fillRect(x + 1, y + 1, fillW, h - 2, ILI9341_ORANGE);

  if (!st5RetryUsed) tftTiny(10, 218, "Retry: AVAILABLE", ILI9341_GREEN);
  else              tftTiny(10, 218, "Retry: USED", ILI9341_RED);
}

// ===================== STAGE 5 LOGIC =====================
void st5StartChallenge(uint32_t now) {
  st5RequiredClaps = (uint8_t)random(1, 4);  // 1..3
  st5GotClaps = 0;

  st5WindowMs = ST5_BASE_MS + (uint32_t)st5RequiredClaps * ST5_PERCLAP_MS; // ~6.5s..9.5s
  st5StartMs = now;

  playTrack(TRK_VERIFY_PROMPT);
}

void st5FailOrRetry() {
  if (!st5RetryUsed) {
    st5RetryUsed = true;

    tftHeader("North Pole Portal");
    tftCenterText(70, "TOO SLOW!", 3, ILI9341_RED);
    tftCenterText(120, "Retry granted", 2, ILI9341_YELLOW);
    tftCenterText(150, "Clap faster!", 2, ILI9341_WHITE);

    playTrack(TRK_VERIFY_FAIL);
    delay(900);
    setState(ST_VERIFY5);
  } else {
    setState(ST_FAIL);
  }
}

// ===================== STAGE 6 LCD  =====================
void st6PrecomputeRing() {
  for (uint8_t i = 0; i < ST6_RING_DOTS; i++) {
    float a = (2.0f * 3.14159f) * ((float)i / (float)ST6_RING_DOTS);
    st6dx[i] = (int16_t)(cosf(a) * ST6_RING_R);
    st6dy[i] = (int16_t)(sinf(a) * ST6_RING_R);
  }
}

void st6UiInit() {
  tftHeader("North Pole Portal");
  tftCenterText(52, "PORTAL OPEN", 3, ILI9341_YELLOW);
  tftTiny(10, 225, "Stage 6 • Gate opening", ILI9341_DARKGREY);

  // base ring dots (dim)
  const RoleDef& R = ROLES[currentRole];
  for (uint8_t i = 0; i < ST6_RING_DOTS; i++) {
    int x = ST6_RING_CX + st6dx[i];
    int y = ST6_RING_CY + st6dy[i];
    tft.fillCircle(x, y, 3, ILI9341_DARKGREY);
    if (i % 3 == 0) tft.drawCircle(x, y, 3, R.accent);
  }

  // center hint
  tftCenterText(178, "Opening portal...", 2, ILI9341_CYAN);

  st6UiInitDone = true;
  st6LastUiFrame = millis();
}

bool st6PickConfettiPos(int &x, int &y) {
  bool topBand = (random(2) == 0);
  x = random(6, 314);

  if (topBand) y = random(78, 108);
  else         y = random(188, 212);

  int dx = x - ST6_RING_CX;
  int dy = y - ST6_RING_CY;
  if ((dx*dx + dy*dy) < (55*55)) return false;
  return true;
}

uint16_t st6RandomConfettiColor(uint16_t accent) {
  switch (random(5)) {
    case 0: return ILI9341_WHITE;
    case 1: return ILI9341_YELLOW;
    case 2: return ILI9341_CYAN;
    case 3: return accent;
    default: return ILI9341_ORANGE;
  }
}

void st6ConfettiTick(uint16_t accent) {
  for (int i = 0; i < ST6_CONFETTI; i++) {
    if (st6cx[i] >= 0) tft.drawPixel(st6cx[i], st6cy[i], ILI9341_BLACK);
  }

  for (int i = 0; i < ST6_CONFETTI; i++) {
    int x, y;
    bool ok = false;
    for (int k = 0; k < 6; k++) {
      ok = st6PickConfettiPos(x, y);
      if (ok) break;
    }
    if (!ok) { x = random(6, 314); y = random(78, 212); }

    st6cx[i] = x;
    st6cy[i] = y;
    st6cc[i] = st6RandomConfettiColor(accent);
    tft.drawPixel(st6cx[i], st6cy[i], st6cc[i]);
  }
}

void st6DrawCelebrationStatic() {
  tft.fillRect(0, 74, tft.width(), 150, ILI9341_BLACK);
}

void st6DrawCelebrationText(uint32_t now) {
  const RoleDef& R = ROLES[currentRole];

  uint16_t pulse = ((now / 220) % 2 == 0) ? ILI9341_GREEN : ILI9341_YELLOW;

  tftCenterText(86,  "ACCESS GRANTED", 3, pulse);
  tftCenterText(118, R.name,           2, R.accent);

  char buf[32];
  sprintf(buf, "NP-%d", staffId);
  tftCenterText(144, buf,              3, ILI9341_WHITE);

  tftCenterText(176, "ENJOY YOUR STAY!", 2, ILI9341_CYAN);
}

void st6UiTick(uint32_t now) {
  if (now - st6LastUiFrame < ST6_UI_FRAME_MS) return;
  st6LastUiFrame = now;

  uint32_t e = msInState();
  const RoleDef& R = ROLES[currentRole];

  // ---- Phase 1: Ring spin ----
  if (e < ST6_RING_MS) {
    // progress 0..1
    float p = (float)e / (float)ST6_RING_MS;
    if (p < 0) p = 0;
    if (p > 1) p = 1;

    int idx = (int)(p * (ST6_RING_DOTS - 1));
    if (idx < 0) idx = 0;
    if (idx >= ST6_RING_DOTS) idx = ST6_RING_DOTS - 1;

    // erase previous highlight back to dim
    if (st6LastDot >= 0 && st6LastDot != idx) {
      int px = ST6_RING_CX + st6dx[st6LastDot];
      int py = ST6_RING_CY + st6dy[st6LastDot];
      tft.fillCircle(px, py, 3, ILI9341_DARKGREY);
      if (st6LastDot % 3 == 0) tft.drawCircle(px, py, 3, R.accent);
    }

    // highlight current dot
    int x = ST6_RING_CX + st6dx[idx];
    int y = ST6_RING_CY + st6dy[idx];
    tft.fillCircle(x, y, 4, ILI9341_WHITE);
    tft.drawCircle(x, y, 4, R.accent);
    st6LastDot = idx;

    // status line (small update area)
    tft.fillRect(0, 178, tft.width(), 22, ILI9341_BLACK);
    if (p < 0.35f) tftCenterText(178, "Synchronizing...", 2, ILI9341_CYAN);
    else if (p < 0.75f) tftCenterText(178, "Aligning portal...", 2, ILI9341_YELLOW);
    else tftCenterText(178, "Almost there...", 2, ILI9341_GREEN);

    return;
  }

  // ---- Phase 2: Celebration ----
  if (!st6CeleStaticDrawn) {
    st6DrawCelebrationStatic();
    st6CeleStaticDrawn = true;
  }

  st6ConfettiTick(R.accent);
  st6DrawCelebrationText(now);
}

// ===================== SETUP =====================
void setup() {
  Serial.begin(115200);
  delay(200);

  pinMode(BTN_PIN, INPUT_PULLUP);

  SPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS);
  tft.begin();
  tft.setRotation(1);

  strip.begin();
  strip.setBrightness(LED_BRIGHTNESS);
  strip.clear();
  strip.show();

  Wire.begin(I2C_SDA, I2C_SCL);
  if (!lox.begin()) {
    tftHeader("North Pole Portal");
    tftCenterText(90, "ToF FAIL", 3, ILI9341_RED);
    tftCenterText(140, "Check wiring", 2, ILI9341_RED);
    while (1) delay(10);
  }

  dfSerial.begin(9600, SERIAL_8N1, DF_RX, DF_TX);
  delay(1200);
  if (!dfplayer.begin(dfSerial)) {
    tftHeader("North Pole Portal");
    tftCenterText(90, "DFP FAIL", 3, ILI9341_RED);
    tftCenterText(140, "Check UART/power", 2, ILI9341_RED);
    while (1) delay(10);
  }
  dfplayer.volume(22);

  randomSeed((uint32_t)micros());
  micInitSampler();

  st6PrecomputeRing();
  setState(ST_IDLE);
}

// ===================== LOOP =====================
void loop() {
  uint32_t now = millis();

  // ToF polling
  if (now - lastToF >= TOF_POLL_MS) {
    lastToF = now;
    dist = readDistanceMM();
  }

  // Mic tick (always)
  micTickSampler(now);

  // Leave safety (reset if they walk away)
  if ((dist == -1 || dist > LEAVE_MM) && state != ST_IDLE) {
    setState(ST_IDLE);
    return;
  }

  // LED pacing
  if (now - lastLedFrame >= LED_FRAME_MS) {
    lastLedFrame = now;

    if (state == ST_IDLE) {
      if (IDLE_LED_MODE == LED_COMET_SWEEP) ledsIdleCometSweep(now);
      else                                 ledsIdleRadarPing(now);
    } else if (state == ST_DETECT) {
      ledsScannerSweep(now);
    } else if (state == ST_INVITE) {
      ledsInviteCandyGate(now);
    } else if (state == ST_UNLOCK) {
      ledsDoorUnlock(now);
    } else if (state == ST_ROLE4) {
      ledsRoleAssign(now);
    } else if (state == ST_VERIFY5) {
      ledsVerifyClapRush(now);
    } else if (state == ST_PASS) {
      ledsRoleAssign(now);
    } else if (state == ST_GATE6) {
      ledsStage6(now);
    } else if (state == ST_FAIL) {
      for (int i = 0; i < LED_COUNT; i++) strip.setPixelColor(i, rgb(80, 0, 0));
      if ((now % 500) < 120) for (int s = 0; s < 20; s++) strip.setPixelColor(random(LED_COUNT), rgb(255,0,0));
      strip.show();
    }
  }

  // ===================== STATE MACHINE =====================
  if (state == ST_IDLE) {
    if (!idleUiInitDone) idleUiInit();
    idleUiTick(now);

    if (dist > 0 && dist < DETECT_MM) {
      if (nearCount < 255) nearCount++;
    } else {
      nearCount = 0;
    }
    if (nearCount >= DETECT_CONFIRM_READS) setState(ST_DETECT);
  }

  else if (state == ST_DETECT) {
    if (!st2UiInitDone) st2ScanUiInit();
    st2ScanUiTick();

    if (!st2AudioStarted) {
      playTrack(TRK_SCAN);
      st2AudioStarted = true;
    }
    if (msInState() >= ST2_SCAN_MS) setState(ST_INVITE);
  }

  else if (state == ST_INVITE) {
    if (!inviteUiInitDone) inviteUiInit();
    inviteUiTick(now);

    if (msInState() >= INVITE_TIMEOUT_MS) { setState(ST_IDLE); return; }
    if (buttonPressed()) setState(ST_UNLOCK);
  }

  else if (state == ST_UNLOCK) {
    if (!unlockUiInitDone) unlockUiInit();
    unlockUiTick();

    if (!unlockAudioStarted) {
      playTrack(TRK_UNLOCK);
      unlockAudioStarted = true;
    }
    if (msInState() >= UNLOCK_MS) setState(ST_ROLE4);
  }

  else if (state == ST_ROLE4) {
    if (!st4UiInitDone) {
      st4InitRoleData();
      st4UiInit();

      playTrack(TRK_ROLE_ASSIGN);
      st4UiInitDone = true;
    }

    st4UiTick(now);

    uint32_t e = msInState();
    uint32_t tCstart = ST4_PHASE_A_MS + ST4_PHASE_B_MS;
    uint32_t tDstart = ST4_PHASE_A_MS + ST4_PHASE_B_MS + ST4_PHASE_C_MS;

    if (!st4StampPlayed && e >= tCstart) {
      playTrack(TRK_STAMP_SFX);
      st4StampPlayed = true;
    }

    if (!st4RobotPlayed && e >= (tCstart + 350)) {
      playTrack(TRK_ROBOT_ID);
      st4RobotPlayed = true;
    }

    if (!st4RoleVoicePlayed && e >= (tDstart + 350)) {
      playTrack(ROLES[currentRole].voiceTrack);
      st4RoleVoicePlayed = true;
    }

    if (msInState() >= ST4_TOTAL_MS) {
      st5RetryUsed = false;
      setState(ST_VERIFY5);
    }
  }

  else if (state == ST_VERIFY5) {
    if (!st5UiInitDone) {
      st5UiInitDone = true;
      st5UiInit();
      st5AudioStartPlayed = false;
      st5StartMs = 0;
      st5GotClaps = 0;
    }

    if (!st5AudioStartPlayed) {
      playTrack(TRK_VERIFY_START);
      st5AudioStartPlayed = true;
    }

    if (msInState() >= ST5_INTRO_MS && st5StartMs == 0) {
      st5StartChallenge(now);
    }

    if (clapEvent && st5StartMs != 0) {
      uint32_t elapsed = now - st5StartMs;
      if (elapsed < st5WindowMs) {
        if (st5GotClaps < st5RequiredClaps) {
          st5GotClaps++;
          st5ClapFlashUntil = now + 180;

          if (st5GotClaps >= st5RequiredClaps) {
            setState(ST_PASS);
            return;
          }
        }
      }
    }

    if (st5StartMs != 0) {
      uint32_t elapsed = now - st5StartMs;
      if (elapsed >= st5WindowMs) {
        st5FailedFlash = true;
        st5FailOrRetry();
        return;
      }
    }

    st5UiTick(now);
  }

  else if (state == ST_PASS) {
    static bool passUi = false;
    if (!passUi) {
      tftHeader("North Pole Portal");
      tftCenterText(70, "APPROVED!", 3, ILI9341_GREEN);
      tftCenterText(120, "Access granted", 2, ILI9341_YELLOW);
      tftCenterText(150, "Opening portal...", 2, ILI9341_WHITE);
      playTrack(TRK_VERIFY_SUCCESS);
      passUi = true;
    }

    // After a short beat, go Stage 6
    if (msInState() >= 2000) {
      passUi = false;
      setState(ST_GATE6);
    }
  }

  else if (state == ST_GATE6) {
    if (!st6UiInitDone) {
      st6UiInit();
      st6UiInitDone = true;
    }

    // Audio timing
    uint32_t e = msInState();
    if (!st6GateSfxPlayed) {
      playTrack(TRK_GATE_OPEN);
      st6GateSfxPlayed = true;
    }
    if (!st6WhooshPlayed && e >= (uint32_t)(ST6_RING_MS * 0.70f)) {
      playTrack(TRK_GATE_WHOOSH);
      st6WhooshPlayed = true;
    }
    if (!st6JinglePlayed && e >= ST6_RING_MS) {
      playTrack(TRK_GATE_JINGLE);
      st6JinglePlayed = true;
    }

    // LCD tick
    st6UiTick(now);

    if (msInState() >= ST6_TOTAL_MS) {
      setState(ST_IDLE);
    }
  }

  else if (state == ST_FAIL) {
    static bool failUi = false;
    if (!failUi) {
      tftHeader("North Pole Portal");
      tftCenterText(70, "DENIED", 3, ILI9341_RED);
      tftCenterText(120, "Verification failed", 2, ILI9341_YELLOW);
      tftCenterText(150, "Step back to retry", 2, ILI9341_WHITE);
      playTrack(TRK_VERIFY_FAIL);
      failUi = true;
    }
    if (msInState() >= 2200) {
      failUi = false;
      setState(ST_IDLE);
    }
  }
}


Closing

The North Pole Welcome Gate was built to create an instant holiday moment: it attracts attention from across the room, invites interaction, rewards participation with a fun role reveal and mini-game, and finishes with a strong celebration. By combining TFT animations, addressable LED effects, and voice/music, it feels like a single cohesive experience rather than separate modules. The enclosure and battery power make it portable and presentable, and the LED strip can be mounted to almost any structure to scale up the visual impact even further.

References

ESP32-S3 documentation (Espressif Systems)
Adafruit GFX Library documentation (Adafruit)
Adafruit ILI9341 Library documentation (Adafruit)
Adafruit NeoPixel (WS2812B) Library documentation (Adafruit)
Adafruit VL53L0X Library / guide (Adafruit)
DFPlayer Mini documentation / examples (DFRobot)
Autodesk Fusion 360 documentation (for CAD design workflow)

Audio: Background music and sound effects sourced from Pixabay.
Voice lines: Generated using text-to-speech (TTS).

Category : Project