animemecha
Created December 27, 2025

Spirit Detector: An Interactive Light-Based Spirit Counter

This is a cursed project that forces people to converse with you at an anime convention, but in return, you "steal their soul".

4

Things used in this project

Hardware components

SparkFun ESP32 Thing
SparkFun ESP32 Thing
×1
MSP2401
a 2.4" No Touch TFT Module (SKU MSP2401) found on aliexpress. Cost like 2.97 USD at the time
×1
onsemi QRD1114
Cost 1.33 USD on digikey. Found in a box of parts dating back to 2008.
×2
Resistor 10k ohm
Resistor 10k ohm
×2
Through Hole Resistor, 470 ohm
Through Hole Resistor, 470 ohm
×2
18650 Battery
A single cell 18560 that I found in a drawer salvaged from a chainsaw battery
×1
Nintendo Power Glove
A Nintendo Power Glove. Pretty useless 30+ years ago, still useless today. Useful for attention grabbing if anything, but can be replaced with pretty much anything
×1
Battery Holder, 18650 x 1
Battery Holder, 18650 x 1
a battery holder for the 18650 battery
×1
prefboard
some spare prefboard found lying around
×1

Software apps and online services

VS Code
Microsoft VS Code
PlatformIO Core
PlatformIO Core

Story

Read more

Schematics

soulstealerschematic_ueNf1DIkcU.png

Code

main.cpp

C/C++
#include <TFT_eSPI.h>
#include <SPI.h>
#include <AnimatedGIF.h>
#include <SPIFFS.h>
#include "GIFDraw.h"

#define QRD1114_1 34
#define QRD1114_2 33
#define Button_PIN 0

AnimatedGIF gif;
TFT_eSPI tft = TFT_eSPI();
// Use a dedicated SPIClass instance for SD operations so we can control transactions
SPIClass sdSPI(VSPI);
File gifFile;
// Mutex used to serialize access to SPI/TFT/SD to avoid concurrency races
SemaphoreHandle_t spiMutex = NULL;

unsigned long lastUpdate = 0;
int mode = 0; // 0 = idle, 1 = detecting, 2 = alert
unsigned long modeStart = 0;
unsigned long soulsCollected = 0;
const int sampleCount = 10;
int sensor1Samples[sampleCount];
int sensor2Samples[sampleCount];
unsigned long lastSoulTime = 0;
int sampleIndex = 0;
const unsigned long soulCooldown = 1000; // 3 seconds between soul detections

//For Button 0 press
void IRAM_ATTR handleButtonPress();  // interrupt handler
volatile bool buttonPressed = false;
volatile bool alreadyPlayed = false;

// Bar positions and dimensions
const int barCount = 4;
const int barWidth = 50;
const int barSpacing = 10;
const int barBaseY = 220;
const int barMaxHeight = 120;
int barHeights[barCount] = {0, 0, 0, 0};


// --------------------- VISUAL EFFECTS ---------------------

void drawIdleScreen() {
  tft.fillScreen(TFT_BLACK);
  tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
  tft.setTextDatum(MC_DATUM);
  tft.drawString("Scanning for", tft.width() / 2, tft.height() / 2 - 20);
  tft.drawString("Cursed Spirits...", tft.width() / 2, tft.height() / 2 + 10);
  Serial.println("[Idle] Scanning...");
}

void drawDetection() {
  Serial.println("[Detection] Energy fluctuation detected!");
  tft.fillScreen(TFT_BLACK);

  for (int r = 10; r < 100; r += 10) {
    uint16_t color = tft.color565(0, random(50, 255), random(50, 255)); // eerie blue-green glow
    tft.drawCircle(tft.width() / 2, tft.height() / 2, r, color);
    delay(40);
  }
}

void drawAlert() {
  Serial.println("[Alert] CURSED SPIRIT DETECTED!");
  for (int i = 0; i < 3; i++) {
    tft.fillScreen(TFT_RED);
    delay(150);
    tft.fillScreen(TFT_BLACK);
    delay(150);
  }

  tft.setTextColor(TFT_RED, TFT_BLACK);
  tft.setTextDatum(MC_DATUM);
  tft.drawString("CURSED SPIRIT", tft.width() / 2, tft.height() / 2 - 20);
  tft.drawString("DETECTED!", tft.width() / 2, tft.height() / 2 + 10);
}

void flicker() {
  int x = random(tft.width());
  int y = random(tft.height());
  uint16_t color = (random(10) > 7) ? TFT_DARKGREY : TFT_BLACK;
  tft.drawPixel(x, y, color);
}

// Callback functions for the AnimatedGIF library (SPIFFS)
void *fileOpen(const char *filename, int32_t *pFileSize)
{
  // Acquire SPI/TFT/SD mutex while performing filesystem operations
  if (spiMutex) xSemaphoreTake(spiMutex, portMAX_DELAY);
  gifFile = SPIFFS.open(filename, FILE_READ);
  if (gifFile) {
    *pFileSize = gifFile.size();
  } else {
    Serial.println("Failed to open GIF file from SPIFFS!");
    *pFileSize = 0;
  }
  if (spiMutex) xSemaphoreGive(spiMutex);
  return &gifFile;
}

void fileClose(void *pHandle)
{
  if (spiMutex) xSemaphoreTake(spiMutex, portMAX_DELAY);
  gifFile.close();
  if (spiMutex) xSemaphoreGive(spiMutex);
}

int32_t fileRead(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen)
{
  int32_t iBytesRead = iLen;
  if ((pFile->iSize - pFile->iPos) < iLen)
    iBytesRead = pFile->iSize - pFile->iPos;
  if (iBytesRead <= 0)
    return 0;

  if (spiMutex) xSemaphoreTake(spiMutex, portMAX_DELAY);
  gifFile.seek(pFile->iPos);
  int32_t bytesRead = gifFile.read(pBuf, iBytesRead);
  pFile->iPos += bytesRead;
  if (spiMutex) xSemaphoreGive(spiMutex);
  return bytesRead;
}

int32_t fileSeek(GIFFILE *pFile, int32_t iPosition)
{
  if (iPosition < 0)
    iPosition = 0;
  else if (iPosition >= pFile->iSize)
    iPosition = pFile->iSize - 1;
  pFile->iPos = iPosition;
  if (spiMutex) xSemaphoreTake(spiMutex, portMAX_DELAY);
  gifFile.seek(pFile->iPos);
  if (spiMutex) xSemaphoreGive(spiMutex);
  return iPosition;
}

void playGif(String test){
  String currentGifPath = test;
  File exisitngGif = SPIFFS.open(currentGifPath.c_str(),FILE_READ);
  Serial.printf("Attempting to open the gif");
  gif.begin(BIG_ENDIAN_PIXELS);
  bool gifOPened= gif.open(currentGifPath.c_str(), fileOpen, fileClose, fileRead, fileSeek, GIFDraw);
  if(gifOPened){
  Serial.printf("gif opened");
    tft.startWrite();
    while (gif.playFrame(true, NULL))
    {
      delay(1);
    }
    gif.close();
    tft.endWrite();
  }
  else{
    Serial.printf("Failed to open GIF for playback: %s\n", currentGifPath.c_str());
    delay(1000);
  }
}

void playRandomGif(){
  int randNum = random(0,7);

  switch(randNum){
    case 0:
      playGif("/korone.gif");
    break;

    case 1:
      tft.fillScreen(TFT_WHITE);
      playGif("/subaduck.gif");
      playGif("/subaduck.gif");
    break;

    case 2:
      playGif("/takohacker.gif");
      playGif("/takohacker.gif");
      playGif("/takohacker.gif");
    break;

    case 3:
      playGif("/fubukiburgershake.gif");
      playGif("/fubukiburgershake.gif");
      playGif("/fubukiburgershake.gif");
      playGif("/fubukiburgershake.gif");
      playGif("/fubukiburgershake.gif");
    break;

    case 4:
      playGif("/takoyawn.gif");
    break;

    case 5:
      playGif("/spin2win.gif");
      playGif("/spin2win.gif");
    break;
    case 6:
      playGif("/subaduckfactory.gif");
      playGif("/subaduckfactory.gif");
      playGif("/subaduckfactory.gif");
      playGif("/subaduckfactory.gif");
    break;

    default:
    Serial.println("default case");
    break;
  }
  tft.fillScreen(TFT_BLACK);
}


void drawSoulScreen() {

  tft.fillScreen(TFT_BLACK);
  tft.setTextColor(TFT_RED, TFT_BLACK);
  tft.setTextSize(2);

  // Draw the "Souls Collected" count
  tft.setCursor(40, 12);
  tft.print("x ");
  tft.print(soulsCollected);

  // Optional fancy effect
  tft.drawRect(5, 5, 100, 30, TFT_DARKGREY);
}


void collectSouls() {
  // Shift in new readings
  sensor1Samples[sampleIndex] = analogRead(QRD1114_1);
  sensor2Samples[sampleIndex] = analogRead(QRD1114_2);
  sampleIndex = (sampleIndex +1)%sampleCount;

  // Compute smoothed averages
  int avg1 = 0, avg2 = 0;
  for (int i = 0; i < sampleCount; i++) {
    avg1 += sensor1Samples[i];
    avg2 += sensor2Samples[i];
  }
  avg1 /= sampleCount;
  avg2 /= sampleCount;

  // Debug output
  Serial.print("Sensor1: "); Serial.print(avg1);
  Serial.print("  Sensor2: "); Serial.println(avg2);

  // Randomized threshold (to simulate "unstable cursed energy")
  int baseThreshold = 3000;
  // int randomOffset = random(-100, 100);
  // int threshold = baseThreshold + randomOffset;
  int threshold = baseThreshold;

  // Check for dark (low reflectivity)
  bool sensor1Dark = avg1 < threshold;
  bool sensor2Dark = avg2 < threshold;

  // Soul detection logic
  if (sensor1Dark && sensor2Dark) {
    unsigned long now = millis();
    if (now - lastSoulTime > soulCooldown) {
      soulsCollected++;
      alreadyPlayed=false;
      lastSoulTime = now;
      Serial.print("Soul detected! Total: ");
      Serial.println(soulsCollected);
    }
  }

  if(soulsCollected > 0 && soulsCollected%10==0 && !alreadyPlayed){
    playRandomGif();
    alreadyPlayed=true;
  }
}

void IRAM_ATTR handleButtonPress() {
  // Debounce (basic, ~50 ms lockout)
  static unsigned long lastInterrupt = 0;
  unsigned long now = millis();
  if (now - lastInterrupt > 50) {
    buttonPressed = true;
    lastInterrupt = now;
  }
}

void drawSoulCounter() {
  tft.fillRect(0, 0, tft.width(), 30, TFT_BLACK);
  tft.setCursor(10, 5);
  tft.print("souls harvested:");
  tft.print(soulsCollected);
}

void drawBars() {
  int startX = 20;

  for (int i = 0; i < barCount; i++) {
    // erase old bar
    tft.fillRect(startX + i * (barWidth + barSpacing),
                 barBaseY - barMaxHeight, barWidth, barMaxHeight, TFT_BLACK);

    // draw new bar
    int barHeight = barHeights[i];
    int barY = barBaseY - barHeight;

    uint16_t color = TFT_GREEN;
    if (barHeight > barMaxHeight * 0.7)
      color = TFT_RED;
    else if (barHeight > barMaxHeight * 0.4)
      color = TFT_YELLOW;

    tft.fillRect(startX + i * (barWidth + barSpacing),
                 barY, barWidth, barHeight, color);
  }
}

void updateBars() {
  int val1 = analogRead(QRD1114_1);
  int val2 = analogRead(QRD1114_2);

  // Normalize to 0–100
  int n1 = map(val1, 0, 4095, 0, 100);
  int n2 = map(val2, 0, 4095, 0, 100);

  // Create 4 pseudo-randomized values based on the sensors
  int pseudo[4];
  pseudo[0] = n1 + random(-10, 10);
  pseudo[1] = n2 + random(-10, 10);
  pseudo[2] = (n1 + n2) / 2 + random(-15, 15);
  pseudo[3] = (n1 / 2 + n2) / 2 + random(-20, 20);

  for (int i = 0; i < 4; i++) {
    pseudo[i] = constrain(pseudo[i], 0, 100);
    barHeights[i] = map(pseudo[i], 0, 100, 0, barMaxHeight);
  }

  drawBars();
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  tft.init();
  tft.setRotation(1);
  tft.fillScreen(TFT_BLACK);
  tft.setTextSize(4);
  soulsCollected=0;

  pinMode(QRD1114_1,INPUT);
  pinMode(QRD1114_2,INPUT);
  pinMode(Button_PIN,INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(Button_PIN),handleButtonPress,FALLING);

  Serial.println("Soul Harvestor initialized.");

  // Initialize SPIFFS
  Serial.println("Initialize SPIFFS...");
  if (!SPIFFS.begin(true)) {
    Serial.println("SPIFFS initialization failed!");
    tft.fillScreen(TFT_RED);
    tft.setCursor(10, 30);
    tft.print("SPIFFS FAILED");
    return;
  }
  Serial.println("SPIFFS initialized successfully.");

}


void loop() {
  if(buttonPressed){
    buttonPressed=false;
    playRandomGif();
  }
  collectSouls();
  updateBars();
  drawSoulCounter();
  delay(600);
}

platformio.ini

INI
This is the platformio.ini file config made for the project
; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[env:esp32thing]
platform = espressif32
board = esp32thing
framework = arduino
lib_deps = bodmer/TFT_eSPI @^2.5.43
        bitbank2/AnimatedGIF
monitor_speed = 115200
upload_port = COM3
board_build.partitions = partitions.csv

User_setup.h

C Header File
//Coyp and paste this file to the build/libdeps/esp32thing/TFT_eSPI/TFT_eSPI.h in case you did a clean build
#define ST7789_DRIVER

#define TFT_WIDTH  240
#define TFT_HEIGHT 320

#define TFT_RGB_ORDER TFT_BGR
#define TFT_INVERSION_OFF

#define TFT_BL 21
#define TFT_MOSI  23
#define TFT_SCLK  18
#define TFT_CS    15  // or -1 if tied to GND
#define TFT_DC    2
#define TFT_RST   4

#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_FONT4
#define LOAD_FONT6
#define LOAD_FONT7
#define LOAD_FONT8
#define LOAD_GFXFF

// #define TAB_COLOUR -1

partitions.csv

VBScript
This is the partition.csv file used to resize the ESP32
# Name,   Type, SubType, Offset,   Size,     Flags
nvs,      data, nvs,     0x9000,   0x5000,
otadata,  data, ota,     0xe000,   0x2000,
app0,     app,  ota_0,   0x10000, 0x180000,   
spiffs,   data, spiffs,  0x190000,0x250000,   
coredump, data, coredump,0x3F0000,0x10000,

GIFDraw.h

C Header File
#ifndef GIFDRAW_H
#define GIFDRAW_H

#include <AnimatedGIF.h>

// Declare the callback
void GIFDraw(GIFDRAW *pDraw);

#endif

GIFDraw.cpp

C/C++
#include <TFT_eSPI.h>
#include <AnimatedGIF.h>
#include<stdint.h>

// External display instance (must match your main file)
extern TFT_eSPI tft;

// Define screen parameters
#define DISPLAY_WIDTH  tft.width()
#define DISPLAY_HEIGHT tft.height()
#define BUFFER_SIZE    256

#ifdef USE_DMA
  uint16_t usTemp[2][BUFFER_SIZE];
#else
  uint16_t usTemp[1][BUFFER_SIZE];
#endif
static bool dmaBuf = 0;

// -----------------------------------------------------------------------------
// GIFDraw: called by AnimatedGIF for every line of pixels
// -----------------------------------------------------------------------------
void GIFDraw(GIFDRAW *pDraw)
{
  uint8_t *s;
  uint16_t *d, *usPalette;
  int x, y, iWidth, iCount;

  // Bounds check
  iWidth = pDraw->iWidth;
  if (iWidth + pDraw->iX > DISPLAY_WIDTH)
    iWidth = DISPLAY_WIDTH - pDraw->iX;

  usPalette = pDraw->pPalette;
  y = pDraw->iY + pDraw->y; // current output line

  if (y >= DISPLAY_HEIGHT || pDraw->iX >= DISPLAY_WIDTH || iWidth < 1)
    return;

  s = pDraw->pPixels;

  // Disposal method 2 = restore to background
  if (pDraw->ucDisposalMethod == 2)
  {
    for (x = 0; x < iWidth; x++)
    {
      if (s[x] == pDraw->ucTransparent)
        s[x] = pDraw->ucBackground;
    }
    pDraw->ucHasTransparency = 0;
  }

  // Handle transparency
  if (pDraw->ucHasTransparency)
  {
    uint8_t *pEnd, c, ucTransparent = pDraw->ucTransparent;
    pEnd = s + iWidth;
    x = 0;
    iCount = 0;
    while (x < iWidth)
    {
      c = ucTransparent - 1;
      d = &usTemp[0][0];
      while (c != ucTransparent && s < pEnd && iCount < BUFFER_SIZE)
      {
        c = *s++;
        if (c == ucTransparent)
        {
          s--;
        }
        else
        {
          *d++ = usPalette[c];
          iCount++;
        }
      }
      if (iCount)
      {
        tft.setAddrWindow(pDraw->iX + x, y, iCount, 1);
        tft.pushPixels(usTemp[0], iCount);
        x += iCount;
        iCount = 0;
      }

      // Skip transparent run
      c = ucTransparent;
      while (c == ucTransparent && s < pEnd)
      {
        c = *s++;
        if (c == ucTransparent)
          x++;
        else
          s--;
      }
    }
  }
  else
  {
    s = pDraw->pPixels;
    if (iWidth <= BUFFER_SIZE)
      for (iCount = 0; iCount < iWidth; iCount++)
        usTemp[dmaBuf][iCount] = usPalette[*s++];
    else
      for (iCount = 0; iCount < BUFFER_SIZE; iCount++)
        usTemp[dmaBuf][iCount] = usPalette[*s++];

#ifdef USE_DMA
    tft.dmaWait();
    tft.setAddrWindow(pDraw->iX, y, iWidth, 1);
    tft.pushPixelsDMA(&usTemp[dmaBuf][0], iCount);
    dmaBuf = !dmaBuf;
#else
    tft.setAddrWindow(pDraw->iX, y, iWidth, 1);
    tft.pushPixels(&usTemp[0][0], iCount);
#endif

    iWidth -= iCount;
    while (iWidth > 0)
    {
      if (iWidth <= BUFFER_SIZE)
        for (iCount = 0; iCount < iWidth; iCount++)
          usTemp[dmaBuf][iCount] = usPalette[*s++];
      else
        for (iCount = 0; iCount < BUFFER_SIZE; iCount++)
          usTemp[dmaBuf][iCount] = usPalette[*s++];

#ifdef USE_DMA
      tft.dmaWait();
      tft.pushPixelsDMA(&usTemp[dmaBuf][0], iCount);
      dmaBuf = !dmaBuf;
#else
      tft.pushPixels(&usTemp[0][0], iCount);
#endif
      iWidth -= iCount;
    }
  }
}

Credits

animemecha
3 projects • 1 follower

Comments