Schematik build

Vibe Boy

ESP32Beginner30 minutes
Photo of Vibe Boy

Schematik

Last updated October 26, 2026

What you'll build

A Game Boy-style handheld on the M5Stack PaperMono (C153). The 480 × 800 e-paper panel shows the game window at the top and a touch controller underneath: a cross-shaped D-pad, round B and A buttons, Select and Start pills, and speaker slits. The two physical A/B buttons on the side work too, and every A/B press makes a short chiptune click on the onboard buzzer.

When it boots, a VIIIBE BOY logo bounces and glitches on screen, then the game starts. Touch the screen to skip the intro.

The story: bring the lake back online

The built-in game is an original homebrew adventure. It's not a copy of a commercial game, and there's no creature collecting and no combat. A storm has broken the village lake monitor, and your cap-wearing maker has to build a new one from parts lying around the world.

  1. Find a wild ESP32. Head right out of the village into the forest and walk into the patch of tall grass north of the trail. A message pops up: "You have found a wild ESP32. Congratulations." Press A to pick it up or B to walk on. If you said no, step out of the grass and back in to try again.
  2. Grab the sensor. Back in the village, go into the right-hand house and take the donated I²C temperature sensor off the table.
  3. Build the node. Take both parts to the workbench in the left-hand house and press A to wire the sensor and flash the board. A finished node appears on the bench.
  4. Install it at the lake. Walk right through the forest to the lakeside and press A at the monitor post north of the trail. The monitor lights up with a reading, and the village thanks you.

You can collect the two parts in either order. Lost? Press B at any time to open your build log, which always shows the next task. Village signs give directions too: stand next to one and press A.

The world has five screens: Village, Forest, Lakeside, Left House and Right House. The name of the one you're in shows on the bezel under the game window. The scenery changes as the story moves along: the sensor disappears from its mat, the flashed node sits on the workbench, and the lake post shows a status light once it's installed. The temperature readout is part of the story; the game doesn't measure anything real.

Controls

  • D-pad: walk. Touch and hold an arm of the cross.
  • A: enter or leave a house (stand at the door), read a sign, pick up a part, use the workbench or the lake post.
  • B: open your build log. In any text box, A or B closes it.
  • ON/OFF (top bar): pause or resume. Pressing any game button also resumes.
  • SAVE / LOAD (top bar): keep one quick save of the whole game, quest progress included. It lasts until you reboot.
  • Speaker slits: mute or unmute the button clicks.
  • VIBE BOY label: tap it to fully redraw the e-paper and clear ghosting.
  • Side power button: press once to turn on or reset; double-press to turn off.

How it works

The emulator is Peanut-GB, running on its own core at Game Boy speed (about 59.7 frames per second). Another task reads touch and the buttons every few milliseconds, so input stays responsive while the slower e-paper panel catches up.

E-paper can't redraw 60 times a second, so the firmware only updates the parts of the screen that changed, using the panel's fastest monochrome mode. Small updates take about 80 ms. Greys from the original game are drawn with a stable dither pattern, and the panel does a full clean refresh when you move to a new area or have been idle for a while. That makes slower-paced games like this adventure a good fit. Fast action games will feel choppy.

The adventure itself is a real 32 KB Game Boy ROM, built into the firmware. All the game logic, collisions and story text run inside the emulator, just like a cartridge would.

Playing other games

If the PaperMono's filesystem has a /game.gb, Vibe Boy loads that instead of the built-in adventure. It supports original monochrome (DMG) cartridges up to 2 MB; Game Boy Color-only games are rejected. Only use homebrew or ROMs you have the rights to. Cartridge saves for these games are written to flash, so they survive a reboot.

Troubleshooting

  • The screen is sideways. Hold the PaperMono upright with the USB-C port at the bottom. The layout is portrait only.
  • Old pixels are still showing. That's e-paper ghosting. Tap the VIBE BOY label to clean the panel.
  • Nothing moves. The game may be paused. Press any game button or tap ON/OFF.
  • LOAD says EMPTY. Quick saves don't survive a reboot. Press SAVE first.
  • The story started over. Rebooting always starts a fresh story. Use SAVE/LOAD within a session.

Wiring diagram

Assemble it in 1 step

1. Use the built-in controls

No external parts or wires are needed. This game uses the PaperMono's built-in e-paper touch display, A/B buttons, buzzer, battery, and USB-C port.

  • Hold the PaperMono upright with its USB-C port at the bottom.
  • Use the touch D-pad and A/B controls on screen, or the two physical A/B buttons.
  • Briefly press the side power button to turn on or reset. Double-pressing it turns the PaperMono off.

Deploy the firmware

#include <Arduino.h>
#include <M5Unified.h>
#if !defined(CONFIG_SPIRAM_MODE_OCT)
#error "PaperMono needs octal (OPI) PSRAM enabled"
#endif
#include <LittleFS.h>
#include <atomic>
#include <algorithm>
#include <driver/ledc.h>
#include "ui_feedback.h"
#include "boot_logo.h"
#include "bezel_logo.h"
#define ENABLE_SOUND 0
#include "peanut_gb.h"

// Only the emulation task touches gb/cart RAM. Display receives complete frames.
#include "demo_rom.h"
static const uint8_t* const embeddedDemoStart=embeddedDemo;
static const uint8_t* const embeddedDemoEnd=embeddedDemo+sizeof(embeddedDemo);
static gb_s gb;
static M5Canvas canvas(&M5.Display);
static uint8_t *rom, *ram;
static size_t romSize, ramSize;
static uint8_t drawing[160 * 144], latest[160 * 144], snapshot[160 * 144];
static SemaphoreHandle_t frameLock;
static std::atomic<uint8_t> buttons{0};
static std::atomic<bool> paused{false}, cleanRequested{false}, fault{false};
static std::atomic<uint32_t> emulatedFrames{0}, lastInputAt{0};
static std::atomic<uint8_t> injectedButtons{0};
static bool isDemo=false;
static std::atomic<bool> soundMuted{false};
static bool buzzerReady=false;
static uint8_t latestArea=0; // paired with latest pixels under frameLock
static const char* areaNames[]={"VILLAGE", "FOREST", "LAKESIDE", "LEFT HOUSE", "RIGHT HOUSE"};

static void initBuzzer() {
  // PaperMono GPIO/timer/channel from the official showcase.
  ledc_timer_config_t timer={};
  timer.speed_mode=LEDC_LOW_SPEED_MODE; timer.duty_resolution=LEDC_TIMER_10_BIT;
  timer.timer_num=LEDC_TIMER_3; timer.freq_hz=2000; timer.clk_cfg=LEDC_AUTO_CLK;
  if(ledc_timer_config(&timer)!=ESP_OK) { soundMuted.store(true); return; }
  ledc_channel_config_t channel={};
  channel.gpio_num=42; channel.speed_mode=LEDC_LOW_SPEED_MODE;
  channel.channel=LEDC_CHANNEL_7; channel.timer_sel=LEDC_TIMER_3;
  buzzerReady=ledc_channel_config(&channel)==ESP_OK;
  if(!buzzerReady) soundMuted.store(true);
  Serial.printf("BUZZER ready=%d GPIO=42\n",int(buzzerReady));
}
static void buzzerFrequency(uint16_t hz) {
  if(!buzzerReady) return;
  if(hz) {
    ledc_set_freq(LEDC_LOW_SPEED_MODE,LEDC_TIMER_3,hz);
    ledc_set_duty(LEDC_LOW_SPEED_MODE,LEDC_CHANNEL_7,512);
    ledc_update_duty(LEDC_LOW_SPEED_MODE,LEDC_CHANNEL_7);
  } else ledc_stop(LEDC_LOW_SPEED_MODE,LEDC_CHANNEL_7,0);
  Serial.printf("SOUND hz=%u\n",unsigned(hz));
}
static uint32_t generation = 0;
static bool dirty = false;
static String savePath;
static char title[17];
static constexpr int GX=24, GY=72, GW=432, GH=389;
// Session-only quicksave. Only the emulation task reads/writes this slot.
static gb_s* savedCore;
static uint8_t *savedRam, *savedPixels;
static std::atomic<uint8_t> stateRequest{0}, slotStatus{0};
static bool slotValid=false;
struct Key { int x,y,w,h; uint8_t bit; const char* label; };
static const Key keys[] = {
  {81,529,54,54,JOYPAD_UP,""}, {81,637,54,54,JOYPAD_DOWN,""},
  {27,583,54,54,JOYPAD_LEFT,""}, {135,583,54,54,JOYPAD_RIGHT,""},
  {361,545,78,78,JOYPAD_A,"A"}, {275,575,78,78,JOYPAD_B,"B"},
  {168,706,80,48,JOYPAD_SELECT,"SELECT"}, {266,706,80,48,JOYPAD_START,"START"}
};
static bool inside(int x,int y,int rx,int ry,int w,int h) {
  return x>=rx && x<rx+w && y>=ry && y<ry+h;
}
static uint8_t readRom(gb_s*, uint_fast32_t a) { return a<romSize ? rom[a] : 0xff; }
static uint8_t readRam(gb_s*, uint_fast32_t a) { return a<ramSize ? ram[a] : 0xff; }
static void writeRam(gb_s*, uint_fast32_t a,uint8_t v) {
  if(a<ramSize && ram[a]!=v) { ram[a]=v; dirty=true; }
}
static void error(gb_s*, gb_error_e e, uint16_t a) {
  Serial.printf("Emulator error %d at %04x\n", int(e), a);
  fault.store(true); paused.store(true);
  vTaskDelete(nullptr); // Never return into the core after an invalid opcode.
}
static void line(gb_s*, const uint8_t* pixels, uint_fast8_t y) {
  if(y>=144) return;
  memcpy(drawing+y*160,pixels,160);
  if(y==143) {
    xSemaphoreTake(frameLock,portMAX_DELAY);
    memcpy(latest,drawing,sizeof latest);
    latestArea=isDemo?gb.wram[1]:0; ++generation;
    xSemaphoreGive(frameLock);
  }
}
static void save() {
  if(!dirty || !ramSize) return;
  String tmp=savePath+".tmp";
  File f=LittleFS.open(tmp,"w");
  if(!f) { Serial.println("Cannot open save"); return; }
  bool ok=f.write(ram,ramSize)==ramSize; f.close();
  if(ok && LittleFS.rename(tmp,savePath)) { dirty=false; Serial.println("Cartridge RAM saved"); }
  else Serial.println("Save failed; RAM remains dirty");
}
static void reportMemory() {
  const uint32_t heap=ESP.getHeapSize(), freeHeap=ESP.getFreeHeap();
  const uint32_t psram=ESP.getPsramSize(), freePsram=ESP.getFreePsram();
  const uint32_t flash=ESP.getFlashChipSize(), firmware=ESP.getSketchSize();
  const size_t fs=LittleFS.totalBytes(), usedFs=LittleFS.usedBytes();
  Serial.printf("MEM internal-heap used=%u total=%u (%.2f%%) free=%u min-free=%u largest=%u\n",
    heap-freeHeap,heap,heap?100.0*(heap-freeHeap)/heap:0.0,freeHeap,
    ESP.getMinFreeHeap(),ESP.getMaxAllocHeap());
  Serial.printf("MEM PSRAM used=%u total=%u (%.2f%%) free=%u\n",
    psram-freePsram,psram,psram?100.0*(psram-freePsram)/psram:0.0,freePsram);
  Serial.printf("MEM firmware=%u physical-flash=%u (%.2f%%)\n",
    firmware,flash,flash?100.0*firmware/flash:0.0);
  Serial.printf("MEM filesystem used=%u total=%u (%.2f%%)\n",
    unsigned(usedFs),unsigned(fs),fs?100.0*usedFs/fs:0.0);
}
static void inputTask(void*) {
  uint32_t minimumUntil[8]={0}, injectUntil=0;
  uint8_t lastRaw=0, lastReported=0;
  ButtonSound sound;
  uint16_t playing=0;
  for(;;) {
    uint32_t now=millis();
    // Diagnostic pulses use the same joypad path as touch (no emulator shortcuts).
    while(Serial.available()) {
      char c=Serial.read(); uint8_t mask=0;
      switch(c) {
        case 'r': mask=JOYPAD_RIGHT; break; case 'l': mask=JOYPAD_LEFT; break;
        case 'u': mask=JOYPAD_UP; break; case 'd': mask=JOYPAD_DOWN; break;
        case 'a': mask=JOYPAD_A; break; case 'b': mask=JOYPAD_B; break;
        case 'p': paused.store(!paused.load()); break;
        case 'c': cleanRequested.store(true); break;
        case 'm': soundMuted.store(!soundMuted.load()); break;
        case 'i': reportMemory(); break;
        case 'x': injectedButtons.store(0); injectUntil=now; break;
        case 's': stateRequest.store(1); break; case 'o': stateRequest.store(2); break;
      }
      if(mask) { injectedButtons.store(mask); injectUntil=now+750; }
    }
    if(int32_t(now-injectUntil)>=0) injectedButtons.store(0);
    M5.update(); uint8_t held=0;
    for(size_t i=0;i<M5.Touch.getCount();++i) {
      const auto& t=M5.Touch.getDetail(i);
      // PaperMono's portrait raw coordinates already match the physical display.
      // The stock transformed coordinates rescale/swap the 480x800 sensor.
      // Match the official showcase's hal_touch.cpp instead of using t.x/t.y.
      const auto& rawPoint=M5.Touch.getTouchPointRaw(i);
      int tx=rawPoint.x, ty=rawPoint.y;
      if(t.isPressed()) {
        // Generous directional region includes gaps around the visible cross.
        if(inside(tx,ty,15,517,186,186)) {
          int dx=tx-108, dy=ty-610, ax=abs(dx), ay=abs(dy);
          if(ax>14 && ax*2>=ay) held|=dx>0?JOYPAD_RIGHT:JOYPAD_LEFT;
          if(ay>14 && ay*2>=ax) held|=dy>0?JOYPAD_DOWN:JOYPAD_UP;
        }
        for(const auto& k:keys)
          if(inside(tx,ty,k.x,k.y,k.w,k.h)) held|=k.bit;
      }
      if(t.wasPressed()) {
        Serial.printf("TOUCH raw=%d,%d transformed=%d,%d keys=%02x\n",tx,ty,int(t.x),int(t.y),unsigned(held));
        if(inside(tx,ty,16,8,268,44)) paused.store(!paused.load());
        if(inside(tx,ty,284,8,88,44)) stateRequest.store(1);
        if(inside(tx,ty,372,8,92,44)) stateRequest.store(2);
        // Tap the bezel brand to manually clean the panel.
        if(inside(tx,ty,12,461,456,45)) cleanRequested.store(true);
        if(inside(tx,ty,354,708,100,60)) soundMuted.store(!soundMuted.load());
      }
    }
    if(M5.BtnA.isPressed()) held|=JOYPAD_A;
    if(M5.BtnB.isPressed()) held|=JOYPAD_B;
    held|=injectedButtons.load();
    if(held) lastInputAt.store(now);
    // Catch short taps: retain each new press for at least 80 ms.
    uint8_t raw=held;
    uint16_t hz=sound.update(now,((raw&JOYPAD_A)?1:0)|((raw&JOYPAD_B)?2:0),soundMuted.load());
    if(hz!=playing) { buzzerFrequency(hz); playing=hz; }
    for(int bit=0;bit<8;++bit) {
      uint8_t mask=1u<<bit;
      if((raw&mask) && !(lastRaw&mask)) minimumUntil[bit]=now+80;
      if(int32_t(minimumUntil[bit]-now)>0) held|=mask;
    }
    if(raw && !lastRaw && paused.load() && !fault.load()) paused.store(false);
    lastRaw=raw;
    if(held!=lastReported) {
      Serial.printf("INPUT keys=%02x paused=%d\n",unsigned(held),int(paused.load()));
      lastReported=held;
    }
    buttons.store(held);
    vTaskDelay(pdMS_TO_TICKS(4));
  }
}
static void emulatorTask(void*) {
  int64_t next=esp_timer_get_time(); uint32_t lastSave=millis();
  for(;;) {
    uint8_t request=stateRequest.exchange(0);
    if(request==1) {
      memcpy(savedCore,&gb,sizeof gb);
      if(ramSize) memcpy(savedRam,ram,ramSize);
      memcpy(savedPixels,drawing,sizeof drawing);
      slotValid=true; slotStatus.store(1); save();
      cleanRequested.store(true); Serial.println("Session quicksave stored");
    } else if(request==2) {
      if(slotValid) {
        memcpy(&gb,savedCore,sizeof gb);
        if(ramSize) { memcpy(ram,savedRam,ramSize); dirty=true; }
        memcpy(drawing,savedPixels,sizeof drawing);
        xSemaphoreTake(frameLock,portMAX_DELAY);
        memcpy(latest,drawing,sizeof latest);
        latestArea=isDemo?gb.wram[1]:0; ++generation;
        xSemaphoreGive(frameLock);
        slotStatus.store(2); Serial.println("Session quicksave restored");
      } else { slotStatus.store(3); Serial.println("No session quicksave yet"); }
      cleanRequested.store(true); next=esp_timer_get_time();
    }
    if(paused.load()) { save(); next=esp_timer_get_time(); vTaskDelay(pdMS_TO_TICKS(10)); continue; }
    gb.direct.joypad=uint8_t(~buttons.load());
    gb_run_frame(&gb); ++emulatedFrames;
    static uint32_t demoReportAt=0;
    if(isDemo && buttons.load() && millis()-demoReportAt>=250) {
      Serial.printf("DEMO area=%u panel=%u quest=%u sprite x=%u y=%u feet=%u,%u\n",
                    unsigned(gb.wram[1]),unsigned(gb.wram[3]),unsigned(gb.wram[4]),
                    unsigned(gb.oam[1]),unsigned(gb.oam[0]),
                    unsigned(gb.oam[1]-7),unsigned(gb.oam[0]-9));
      demoReportAt=millis();
    }
    if(millis()-lastSave>=30000) { save(); lastSave=millis(); }
    next+=16743; // DMG 59.7275 Hz, independent of slow panel refreshes.
    int64_t wait=next-esp_timer_get_time();
    if(wait>1000) vTaskDelay(pdMS_TO_TICKS(uint32_t(wait/1000)));
    else { vTaskDelay(1); if(wait < -100000) next=esp_timer_get_time(); }
  }
}
static void text(const char* s,int x,int y,int size=2) {
  canvas.setTextSize(size); canvas.setCursor(x,y); canvas.print(s);
}
// Startup only: no emulator/input tasks run until this finite sequence ends.
// Monochrome motion, no alternating full-screen flashes; touch skips the show.
static void startupAnimation() {
  Serial.println("BOOT VIIIBE BOY: Vanity Expanded");
  canvas.fillScreen(TFT_WHITE); canvas.setTextColor(TFT_BLACK,TFT_WHITE);
  text("SCHEMATIK / AFTER HOURS",106,235,2);
  text("TOUCH TO SKIP",162,550,2);
  M5.Display.setEpdMode(m5gfx::epd_mode_t::epd_text);
  canvas.pushSprite(0,0); M5.Display.display(); M5.Display.waitDisplay();
  const int wave[]={0,12,24,30,24,12,0,-12,-24,-30,-24,-12};
  for(int frame=0;frame<20;++frame) {
    M5.update();
    if(M5.Touch.getCount()) { Serial.println("BOOT skipped by touch"); break; }
    canvas.fillRect(0,275,480,250,TFT_WHITE);
    // The three I's and their neighbours bounce independently, then settle.
    int amplitude=frame<4?4:frame<12?3:frame<16?1:0;
    for(int x=0;x<BOOT_LOGO_W;++x) {
      int band=x/42;
      int dy=wave[(band*2+frame)%12]*amplitude/3;
      for(int y=0;y<BOOT_LOGO_H;++y) {
        if(!(bootLogo[y*BOOT_LOGO_STRIDE+x/8] & (0x80>>(x%8)))) continue;
        int glitch=(frame==5 || frame==9) && y/12%2 ? 10 : 0;
        canvas.drawPixel(30+x+glitch,330+y+dy,TFT_BLACK);
      }
    }
    // Dancing circuit traces, chip pins and cross-shaped sparks.
    if(frame<16) {
      for(int i=0;i<6;++i) {
        int x=35+i*78, y=(i%2?456:300)+wave[(frame+i*2)%12]/3;
        int length=8+(frame+i)%4*4;
        canvas.drawLine(x,y,x+length,y,TFT_BLACK);
        canvas.drawLine(x+length,y,x+length,y+(i%2?-8:8),TFT_BLACK);
        canvas.drawCircle(x,y,3,TFT_BLACK);
        canvas.drawLine(x+12,y-8,x+12,y-2,TFT_BLACK);
        canvas.drawLine(x+9,y-5,x+15,y-5,TFT_BLACK);
      }
    }
    const char* caption=frame<6?"ADDING EXTRA I'S...":frame<12?"VIBE OVERLOAD!":"LET'S BUILD SOMETHING.";
    text(caption,114,496,2);
    M5.Display.setEpdMode(m5gfx::epd_mode_t::epd_fastest);
    M5.Display.setClipRect(0,275,480,250); canvas.pushSprite(0,0);
    M5.Display.clearClipRect(); M5.Display.display(); M5.Display.waitDisplay();
    Serial.printf("BOOT frame=%d\n",frame);
    delay(40);
  }
  // Brief final pose; still skippable. Gameplay starts with its usual clean redraw.
  for(int i=0;i<12;++i) { M5.update(); if(M5.Touch.getCount()) break; delay(40); }
  Serial.println("BOOT complete");
}
static void fatal(const char* message) {
  Serial.println(message);
  M5.Display.setEpdMode(m5gfx::epd_mode_t::epd_text);
  M5.Display.fillScreen(TFT_WHITE); M5.Display.setTextColor(TFT_BLACK,TFT_WHITE);
  M5.Display.setTextSize(2); M5.Display.setCursor(20,40); M5.Display.println(message);
  M5.Display.display(); M5.Display.waitDisplay();
  while(true) delay(1000);
}
void setup() {
  Serial.begin(115200); delay(500);
  auto cfg=M5.config(); cfg.clear_display=false;
  cfg.internal_imu=false; cfg.internal_rtc=false; cfg.internal_mic=false; cfg.internal_spk=false;
  M5.begin(cfg); pinMode(42,OUTPUT); digitalWrite(42,LOW);
  initBuzzer();
  // Portrait handheld layout: screen above controls, like the reference photo.
  M5.Display.setAutoDisplay(false); M5.Display.setRotation(0);
  if(M5.Display.width()!=480 || M5.Display.height()!=800) fatal("Expected PaperMono 480x800 portrait");
  // Cached RGB565 PSRAM canvas; gameplay is dithered to fast black/white output.
  canvas.setColorDepth(16);
  if(!canvas.createSprite(480,800)) fatal("Cannot allocate display canvas");
  if(!LittleFS.begin(true)) fatal("Save storage unavailable");
  const char* path=LittleFS.exists("/game.gb") ? "/game.gb" : "/demo.gb";
  isDemo=strcmp(path,"/demo.gb")==0;
  File f;
  if(isDemo) romSize=embeddedDemoEnd-embeddedDemoStart;
  else {
    f=LittleFS.open(path,"r");
    if(!f || f.size()<32768) fatal("Missing/invalid game.gb");
    romSize=f.size();
  }
  if(romSize>2*1024*1024) fatal("ROM too large: this layout supports up to 2 MiB");
  rom=static_cast<uint8_t*>(ps_malloc(romSize));
  if(!rom) fatal("ROM allocation failed");
  if(isDemo) memcpy(rom,embeddedDemoStart,romSize);
  else { if(f.read(rom,romSize)!=romSize) fatal("ROM read failed"); f.close(); }
  // Protect Peanut-GB's header lookup tables from malformed input.
  if(rom[0x148]>8 || rom[0x149]>5 || romSize!=(size_t(32768)<<rom[0x148])) fatal("Unsupported ROM size/header");
  if(rom[0x143]==0xc0) fatal("CGB-only games are not supported (DMG only)");
  if(gb_init(&gb,readRom,readRam,writeRam,error,nullptr)!=GB_INIT_NO_ERROR) fatal("Unsupported cartridge or bad header checksum");
  ramSize=gb_get_save_size(&gb);
  if(ramSize) {
    ram=static_cast<uint8_t*>(ps_malloc(ramSize)); if(!ram) fatal("RAM allocation failed");
    memset(ram,0xff,ramSize);
  }
  // Content-derived filename keeps different cartridges' saves separate.
  uint32_t hash=2166136261u;
  for(size_t i=0;i<romSize;++i) hash=(hash^rom[i])*16777619u;
  char name[32]; snprintf(name,sizeof name,"/save-%08lx.sav",static_cast<unsigned long>(hash)); savePath=name;
  if(ramSize) { File s=LittleFS.open(savePath,"r"); if(s && s.size()==ramSize) s.read(ram,ramSize); }
  savedCore=static_cast<gb_s*>(ps_malloc(sizeof gb));
  savedPixels=static_cast<uint8_t*>(ps_malloc(sizeof drawing));
  if(ramSize) savedRam=static_cast<uint8_t*>(ps_malloc(ramSize));
  if(!savedCore || !savedPixels || (ramSize && !savedRam)) fatal("Quicksave allocation failed");
  gb_get_rom_name(&gb,title); gb_init_lcd(&gb,line);
  gb.direct.frame_skip=0; gb.direct.interlace=0;
  frameLock=xSemaphoreCreateMutex(); if(!frameLock) fatal("Frame lock allocation failed");
  startupAnimation();
  if(xTaskCreatePinnedToCore(inputTask,"input",4096,nullptr,1,nullptr,1)!=pdPASS ||
     xTaskCreatePinnedToCore(emulatorTask,"gameboy",12288,nullptr,1,nullptr,0)!=pdPASS) fatal("Task allocation failed");
  Serial.printf("VIBE BOY READY: %s ROM=%u RAM=%u board=%d\n",title,unsigned(romSize),unsigned(ramSize),int(M5.getBoard()));
}
void loop() {
  static uint32_t seen=UINT32_MAX, partials=0, lastRender=0;
  static bool wasPaused=false, wasFault=false, wasMuted=false;
  static uint8_t wasArea=0;
  static SceneCleanup sceneCleanup;
  static uint8_t lastSlotStatus=0;
  static uint8_t previous[sizeof snapshot];
  bool p=paused.load(), bad=fault.load();
  bool clean=cleanRequested.exchange(false) ||
    sceneCleanup.due(millis(),lastInputAt.load()) ||
    (partials>=40 && millis()-lastInputAt.load()>2000);
  static uint32_t reportAt=millis(), reportedFrames=emulatedFrames.load();
  if(millis()-reportAt>=5000) {
    uint32_t now=millis(), count=emulatedFrames.load();
    Serial.printf("Emulation %.1f fps; paused=%d; portrait rotation=0\n",
      (count-reportedFrames)*1000.0f/(now-reportAt),int(p));
    reportAt=now; reportedFrames=count;
  }
  if(millis()-lastRender<100 && !clean) { delay(2); return; }
  xSemaphoreTake(frameLock,portMAX_DELAY);
  uint32_t frame=generation;
  uint8_t area=latestArea;
  memcpy(snapshot,latest,sizeof snapshot);
  xSemaphoreGive(frameLock);
  bool initial=seen==UINT32_MAX;
  bool sameImage=!initial && memcmp(previous,snapshot,sizeof snapshot)==0;
  uint8_t status=slotStatus.load();
  bool muted=soundMuted.load();
  bool toolbarChanged=p!=wasPaused || bad!=wasFault || status!=lastSlotStatus;
  bool muteChanged=muted!=wasMuted, areaChanged=area!=wasArea;
  bool stateChanged=toolbarChanged || muteChanged || areaChanged;
  seen=frame;
  if(sameImage && !stateChanged && !clean) { delay(10); return; }
  int minX=160,minY=144,maxX=-1,maxY=-1;
  unsigned changedPixels=0;
  for(int y=0;y<144;++y) for(int x=0;x<160;++x)
    if(initial || (snapshot[y*160+x]&LCD_COLOUR)!=(previous[y*160+x]&LCD_COLOUR)) {
      ++changedPixels;
      minX=std::min(minX,x); maxX=std::max(maxX,x);
      minY=std::min(minY,y); maxY=std::max(maxY,y);
    }
  int dirtyX=GX,dirtyY=GY,dirtyW=GW,dirtyH=GH;
  if(maxX>=0) {
    dirtyX=GX+minX*GW/160; dirtyY=GY+minY*GH/144;
    dirtyW=GX+(maxX+1)*GW/160-dirtyX; dirtyH=GY+(maxY+1)*GH/144-dirtyY;
  }
  // Include only the bezel area label on world transitions, not the controls.
  if(areaChanged) {
    dirtyX=GX; dirtyY=GY; dirtyW=GW; dirtyH=428;
  }
  uint32_t frameStart=millis();
  if(!initial) sceneCleanup.changed(frameStart,changedPixels);
  bool redrawUI=initial || stateChanged || clean;
  wasPaused=p; wasFault=bad; lastSlotStatus=status;
  wasMuted=muted; wasArea=area;
  if(redrawUI) {
  canvas.fillScreen(TFT_WHITE); canvas.setTextColor(TFT_BLACK,TFT_WHITE);
  // Compact emulator toolbar, matching the reference rather than an arcade HUD.
  canvas.drawRect(16,12,448,36,TFT_BLACK);
  canvas.drawFastVLine(284,12,36,TFT_BLACK);
  canvas.drawFastVLine(372,12,36,TFT_BLACK);
  text("ON - OFF",26,22,2);
  text(bad?"ERROR":p?"[OFF]":"[ON]",178,22,2);
  text(status==1?"SAVED":"SAVE",296,22,2);
  text(status==2?"LOADED":status==3?"EMPTY":"LOAD",384,22,2);
  // Wide dark bezel and a near-edge-to-edge, correctly proportioned DMG viewport.
  canvas.fillRect(12,56,456,450,TFT_BLACK);
  }
  // Stable spatial gray approximation, with strictly black/white panel data.
  // Unlike native gray waveforms, differential monochrome updates are playable.
  static const uint8_t bayer[2][2]={{0,2},{3,1}};
  for(int y=0;y<144;++y) for(int x=0;x<160;++x) {
    if(!redrawUI && (snapshot[y*160+x]&LCD_COLOUR)==(previous[y*160+x]&LCD_COLOUR)) continue;
    int x0=GX+x*GW/160, x1=GX+(x+1)*GW/160;
    int y0=GY+y*GH/144, y1=GY+(y+1)*GH/144;
    uint8_t shade=snapshot[y*160+x]&LCD_COLOUR;
    if(shade==0 || shade==3) canvas.fillRect(x0,y0,x1-x0,y1-y0,shade?TFT_BLACK:TFT_WHITE);
    else for(int py=y0;py<y1;++py) for(int px=x0;px<x1;++px)
      canvas.drawPixel(px,py,bayer[py&1][px&1]<shade?TFT_BLACK:TFT_WHITE);
  }
  memcpy(previous,snapshot,sizeof snapshot);
  if(redrawUI) {
  if(p || bad) {
    canvas.fillRoundRect(76,227,328,74,7,TFT_WHITE);
    canvas.drawRoundRect(76,227,328,74,7,TFT_BLACK);
    text(bad?"EMULATOR ERROR":"PAUSED",100,240,3);
    text("PRESS A DIRECTION TO RESUME",94,279,1);
  }
  canvas.setTextColor(TFT_WHITE,TFT_BLACK);
  // Native-size Vanity Expanded wordmark: white ink on the unchanged bezel.
  for(int y=0;y<BEZEL_LOGO_H;++y) for(int x=0;x<BEZEL_LOGO_W;++x)
    if(bezelLogo[y*BEZEL_LOGO_STRIDE+x/8] & (0x80>>(x%8)))
      canvas.drawPixel(28+x,471+y,TFT_WHITE);
  canvas.drawRect(202,473,42,24,TFT_WHITE); text("S3",211,479,2);
  if(isDemo && area<5) text(areaNames[area],280,480,2);
  canvas.setTextColor(TFT_BLACK,TFT_WHITE);
  // Classic solid cross. Touch hitboxes share these exact arm positions.
  canvas.fillRoundRect(81,529,54,162,7,TFT_BLACK);
  canvas.fillRoundRect(27,583,162,54,7,TFT_BLACK);
  canvas.drawCircle(108,610,17,TFT_WHITE);
  canvas.fillTriangle(108,542,98,554,118,554,TFT_WHITE);
  canvas.fillTriangle(108,678,98,666,118,666,TFT_WHITE);
  canvas.fillTriangle(40,610,52,600,52,620,TFT_WHITE);
  canvas.fillTriangle(176,610,164,600,164,620,TFT_WHITE);
  // B is lower-left of A, with generous circular touch targets.
  canvas.fillCircle(314,614,35,TFT_BLACK);
  canvas.fillCircle(400,584,35,TFT_BLACK);
  text("B",307,657,2); text("A",393,627,2);
  canvas.fillRoundRect(176,716,64,18,9,TFT_BLACK);
  canvas.fillRoundRect(274,716,64,18,9,TFT_BLACK);
  text("SELECT",175,743,1); text("START",282,743,1);
  // Quiet speaker slits finish the handheld silhouette.
  for(int i=0;i<5;++i) canvas.drawLine(376+i*13,722,362+i*13,756,TFT_BLACK);
  text(muted?"MUTED":"SOUND",373,765,1);
  text(title,20,780,1);
  text(p?"PAUSED":"DMG",400,780,1);
  if(isDemo) text("A: USE B: NEXT TASK",208,780,1);
  }
  // Area changes clean immediately, even during a hold. Ordinary movement
  // retains fast partial updates; other cleanup still waits for idle input.
  bool areaRefresh=areaChanged && !initial && !clean && !toolbarChanged && !muteChanged;
  bool scrub=initial || clean || areaChanged;
  uint32_t renderStart=millis();
  M5.Display.setEpdMode(scrub?m5gfx::epd_mode_t::epd_text:m5gfx::epd_mode_t::epd_fastest);
  if(areaRefresh || (!initial && !toolbarChanged && !muteChanged && !scrub)) {
    // Send only the changed game's bounding rectangle; static controls stay put.
    M5.Display.setClipRect(dirtyX,dirtyY,dirtyW,dirtyH);
  }
  canvas.pushSprite(0,0); M5.Display.clearClipRect();
  // Explicit bounds force the entire game + area-label region through the
  // cleanup waveform, including unchanged pixels; toolbar/controls stay put.
  if(areaRefresh) M5.Display.display(GX,GY,GW,428);
  else M5.Display.display();
  M5.Display.waitDisplay();
  if(areaChanged) Serial.printf("AREA REFRESH area=%u\n",unsigned(area));
  partials=scrub?0:partials+1; lastRender=frameStart;
  if(scrub) sceneCleanup.cleaned();
  Serial.printf("PANEL %s total=%lu ms refresh=%lu ms area=%dx%d\n",scrub?"clean":"fast",
    static_cast<unsigned long>(millis()-frameStart),
    static_cast<unsigned long>(millis()-renderStart),dirtyW,dirtyH);

}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik