Overview
Build a desktop size Pepper's Ghost effect using the Adafruit Qualia ESP32-S3 and a 2.1in round TFT display! 👻👁️
This project shows the infamous animated Monster Eye as a Pepper's Ghost effect inside the Adafruit DIY Snow Globe Kit. The 3D printed enclosure houses all of the electronics and features a clear plastic reflector that you can cut yourself.
Use the built-in UP button on the Qualia ESP32-S3 to cycle through different eyes like snake, demon and the classic hazel style eyes. The DOWN button enables the eye to randomly float up and down, repeatedly creating a ghostly effect.
Page last edited October 07, 2026
Text editor powered by tinymce.
CAD Files
CAD Parts
Individual 3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.
CAD Assembly
This will all be described in detail on the Assembly page. In short:
- Qualia ESP32-S3 is secured to the bottom plate with machine screws
- Display is secured to a case with a inlay holder
- Reflector is fitted into a holder placed inside the snow globe
- Snow globe is fastened to a holder
- Snow globe is secured to the frame using twist lock feature
- The snow globe holder and bottom plate snap fit into the frame
Slice with Settings for PLA Material
Most of the parts were sliced using BambuStudio using the slicer 0.2mm standard settings.
- PLA filament 220c extruder
- 0.2mm layer height
- 8% gyroid infill
- 200mm/s print speed
- Support type tree(auto) with 30º threshold angle
Reflector Template
The reflector can be cut using a printed template or vinyl cutter.
Download the SVG file below. Print the template on paper or 3D printer.
Before printing, ensure the dimensions of the reflector is 105mm x 103mm (4.14in x 4.05in)
To cut, use scissors or hobby knife to cut the 0.5mm thick clear plastic sheet using the template.
If you have a vinyl cutter (like a Cricut machine), upload the SVG and cut using a material profile for 0.5mm thick material.
Build Volume
The parts require a 3D printer with a minimum build volume of 92 (X) x 92 (Y) x 32mm (Z).
Design Source Files
The project assembly was designed in Fusion 360. This can be downloaded in different formats like STEP, STL and more. Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo.
Page last edited October 07, 2026
Text editor powered by tinymce.
Install CircuitPython - Flash Filesystem
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
This microcontroller requires the latest unstable (development) release of CircuitPython. Click below to visit the downloads page on circuitpython.org for your board. Then, Browse S3 under Absolute Newest.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.
Double-click the reset button (highlighted in red above), and you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.
For this board, tap reset and wait about a half a second and then tap reset again.
If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called TFT_S3BOOT.
Drag the adafruit_circuitpython_etc.uf2 file to TFT_S3BOOT.
Page last edited October 07, 2026
Text editor powered by tinymce.
Code the Eye
Eyeball Graphics
After installing CircuitPython, you can drag and drop the eyeball graphics onto your CIRCUITPY drive. The three eyeball types (hazel, demon and snake) are available in this .ZIP file:
Each folder contains a set of .BMP images for that eye, plus a file called *.eye. Copy one of these folders to the CIRCUITPY drive — not the individual files, but the whole folder. Then copy or move the file called *.eye out of the folder and into the drive’s root directory.
Here is what your CIRCUITPY drive should look like after copying the graphics folders and moving the *.eye files to the root directory.
Upload UF2 File
The Qualia S3 code is available as a pre-compiled .UF2 file for the 2.1" 480x480 display that you can drag and drop onto your Qualia S3 board.
Click the button above to download the UF2 file. Save it wherever is convenient for you.
Plug your board into your computer, using a known-good data-sync USB cable, directly, or via an adapter if needed.
Double-click the reset button (highlighted in red above), wait about a half a second and then tap reset again.
You will see a new disk drive appear called TFT_S3BOOT.
Drag the peppersGhostQualia.UF2 file to TFT_S3BOOT.
The code will begin running by animating the hazel eye graphics on the display. Press the UP button to change the eyeball graphics. Press the DOWN button to toggle the eyeball floating offscreen repeatedly.
Source Code in Arduino
Below is the Arduino project sketch. You can compile and upload this sketch locally if you prefer. First, you'll need to install the Adafruit Monster Eyes library. Then, you can edit the code and upload it to your Qualia S3 board.
// SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
//
// SPDX-License-Identifier: MIT
// Monster Eye on Qualia S3 with 2.1" round display
// Button Up changes the eye graphics
// Button Down toggles eyeball floating offscreen repeatedly
#include <Adafruit_Monster_Eyes.h>
#include <Arduino_GFX_Library.h>
#define RGB_W 480
#define RGB_H 480
#define EYE_SCALE 2
// 2.1" round display
Arduino_XCA9554SWSPI expander(PCA_TFT_RESET, PCA_TFT_CS, PCA_TFT_SCK,
PCA_TFT_MOSI, &Wire, 0x3F);
Arduino_ESP32RGBPanel rgbpanel(
TFT_DE, TFT_VSYNC, TFT_HSYNC, TFT_PCLK, TFT_R1, TFT_R2, TFT_R3, TFT_R4,
TFT_R5, TFT_G0, TFT_G1, TFT_G2, TFT_G3, TFT_G4, TFT_G5, TFT_B1, TFT_B2,
TFT_B3, TFT_B4, TFT_B5,
1 /* HSYNC polarity */, 50 /* front porch */, 2 /* pulse width */,
44 /* back porch */,
1 /* VSYNC polarity */, 16 /* front porch */, 2 /* pulse width */,
18 /* back porch */);
Arduino_RGB_Display gfx(
// 2.1" 480x480 round display
RGB_W, RGB_H, &rgbpanel, 0 /* rotation */, true /* auto_flush */,
&expander, GFX_NOT_DEFINED /* RST */, TL021WVC02_init_operations, sizeof(TL021WVC02_init_operations));
// update for config.eye files on your board
// should be in the base directory of CIRCUITPY
const char *eyeFiles[] = {
"/hazel.eye",
"/demon.eye",
"/snake.eye",
};
const uint8_t eyeCount = sizeof(eyeFiles) / sizeof(eyeFiles[0]);
uint8_t eyeIndex = 0;
#define ROLL_DIR -1
#define ROLL_EVERY 5000 // Milliseconds between rolls
#define ROLL_OUT_MS 1000 // Time to slide up out of view
#define ROLL_HOLD_MS 250 // Time spent gone
#define ROLL_IN_MS 850 // Time to rise back into view
#define BUTTON_ACTIVE LOW // The board pulls the button lines high
#define DEBOUNCE_DELAY 50 // Raise if one press counts twice
enum RollState { IDLE, ROLL_AWAY, GONE, ROLL_BACK };
RollState rollState = IDLE;
uint32_t rollStart = 0;
int rollSpan = 0; // Offset at which the eye is fully out of sight
bool rollEnabled = false; // Set true to start rolling from boot
// Ease in and out, the same curve the built-in saccades use.
static float ease(float t) {
if (t < 0.0f)
t = 0.0f;
else if (t > 1.0f)
t = 1.0f;
return 3.0f * t * t - 2.0f * t * t * t;
}
struct Button {
uint8_t pin; // Expander pin PCA_BUTTON_UP
int state; // The debounced reading
int lastReading; // Previous raw reading
unsigned long lastDebounceTime; // When the raw reading last changed
// True once per press
bool pressed() {
const int reading = expander.digitalRead(pin);
if (reading != lastReading) {
lastDebounceTime = millis(); // Still bouncing restart the timer
}
bool fired = false;
if ((millis() - lastDebounceTime) > DEBOUNCE_DELAY) {
if (reading != state) {
state = reading;
if (state == BUTTON_ACTIVE)
fired = true;
}
}
lastReading = reading;
return fired;
}
};
Button btnUp = {PCA_BUTTON_UP, !BUTTON_ACTIVE, !BUTTON_ACTIVE, 0};
Button btnDown = {PCA_BUTTON_DOWN, !BUTTON_ACTIVE, !BUTTON_ACTIVE, 0};
Adafruit_Monster_Eyes eyes(&gfx, EYE_SCALE);
void setup() {
Serial.begin(115200);
// eyes.setVerbose(Serial);
Wire.setClock(1000000);
eyes.keepStorageMounted(true);
eyes.setConfigFile(eyeFiles[eyeIndex]);
if (!eyes.begin()) {
Serial.print("Monster Eyes failed: ");
Serial.println(eyes.errorString());
while (1)
delay(1000);
}
expander.pinMode(PCA_TFT_BACKLIGHT, OUTPUT);
expander.digitalWrite(PCA_TFT_BACKLIGHT, HIGH);
expander.pinMode(PCA_BUTTON_UP, INPUT);
expander.pinMode(PCA_BUTTON_DOWN, INPUT);
rollSpan = eyes.eyeSize() * ROLL_DIR;
rollStart = millis();
}
void loop() {
const uint32_t now = millis();
const uint32_t since = now - rollStart;
if (btnUp.pressed()) {
eyeIndex = (eyeIndex + 1) % eyeCount;
Serial.printf("\nButton: loading %s\n", eyeFiles[eyeIndex]);
const uint32_t t0 = millis();
if (!eyes.loadEye(eyeFiles[eyeIndex])) {
Serial.print(" failed: ");
Serial.println(eyes.errorString());
} else {
Serial.printf(" loaded in %lu ms\n", millis() - t0);
}
return;
}
if (btnDown.pressed()) {
rollEnabled = !rollEnabled;
Serial.printf("Eye roll %s\n", rollEnabled ? "ON" : "OFF");
if (rollEnabled) {
// Start one right away
rollState = ROLL_AWAY;
rollStart = now;
}
}
switch (rollState) {
case IDLE:
if (rollEnabled && (since >= ROLL_EVERY)) {
rollState = ROLL_AWAY;
rollStart = now;
}
break;
case ROLL_AWAY: // Out of sight
eyes.setDrawOffset(0, (int)(rollSpan * ease((float)since / ROLL_OUT_MS)));
if (since >= ROLL_OUT_MS) {
rollState = GONE;
rollStart = now;
}
break;
case GONE:
// Park just past the opposite edge
eyes.setDrawOffset(0, -rollSpan);
if (since >= ROLL_HOLD_MS) {
rollState = ROLL_BACK;
rollStart = now;
}
break;
case ROLL_BACK: // Back into view from the opposite edge
eyes.setDrawOffset(
0, (int)(-rollSpan * (1.0f - ease((float)since / ROLL_IN_MS))));
if (since >= ROLL_IN_MS) {
eyes.setDrawOffset(0, 0); // Exactly centred again
rollState = IDLE;
rollStart = now;
}
break;
}
eyes.animate();
}
Page last edited October 07, 2026
Text editor powered by tinymce.
Assembly
Connect Display to Qualia ESP32-S3
Orient the Qualia PCB and the 2.1in display with both facing up.
Carefully lift the tab from the display connector on the Qualia PCB.
Insert the flex ribbon cable from the display into the Qualia's display connector.
Carefully press down on the the tab to secure the ribbon cable.
Secure Qualia ESP32-S3
Use four M2.5 x 6mm long short machine screws.
Orient the Qualia PCB with the flat edge of the bottom cover.
Place the Qualia PCB onto the four standoffs on the bottom cover with the mounting holes lined up.
Insert and fasten the machine screws to secure the PCB to the bottom cover.
Secure Display
Place the 2.1in display into the display cover with the ribbon cable fitting through the cutout.
Insert the display holder underneath the three nubs on the inner edge of the display cover.
Press the display holder to snap fit them into place with the opening oriented with the display's ribbon cable.
Install Display Case
Insert the display case assembly through the bottom of the frame.
Pull the display case assemble through the frame.
Install Buttons to Frame
Orient the Qualia PCB with the frame so the USB-port and buttons are lined up with the corresponding cutouts.
Insert the button overlay through the matching cutouts on the frame.
Press the bottom cover into the frame to snap fit them together.
Test Eye
Connect the Qualia ESP32-S3 to a 5V power supply or a computers USB hub to power it up and test the circuit.
Install Reflector
Get the clear plastic reflector and reflector holder.
Insert the flat edge of the reflector into the clip on the holder.
Install Reflector to Snow Globe
Remove the stock bottom cover and insert from the snow globe.
While holding the reflector in the holder, insert the reflector through the opening in the snow globe. Bend and flex the reflector so it can fit into the opening.
The reflector may pop out of the holder during installation. Keep the holder inside the snow globe while adjusting the reflector, then reinstall the reflector so the flat edge is fitted into the retaining clip.
Install Snow Globe Holder
Install the snow globe holder by twisting it clock-wise until it's tightly fitted.
Rotate the reflector inside the snow globe so the edge is oriented with the snow globe's seam.
Install Snow Globe to Frame
Fit the snow globe holder into the frame with the two nubs lined up with the matching cutouts.
Once fitted, twist the snow globe holder clock-wise to lock it into place.
The snow globe holder can be rotated so the retaining clip from reflector holder is lined up with the flat edge of the frame.
Page last edited October 07, 2026
Text editor powered by tinymce.
Use
Switch Graphics
Press the UP (middle) button on the Qualia ESP32-S3 to toggle between hazel, demon, and snake eyes.
Page last edited October 07, 2026
Text editor powered by tinymce.