Community project

ESP32 Kare Ekran Projeleri

ESP32
Photo of ESP32 Kare Ekran Projeleri
Generated with AI

Seref Ai

Published September 16, 2026

This project turns an ESP32 with a square display into a Game Boy emulator that plays classic cartridge ROMs. The emulator runs Peanut-GB, a lightweight Game Boy emulator optimized for microcontrollers, and includes a web interface for uploading and managing ROM files over WiFi.

The guide provides a complete parts list, wiring diagram for connecting the display and power, step-by-step assembly instructions, and pre-built firmware. After powering on via USB, users access the web interface through the IP address shown on screen, upload Game Boy ROM files, and launch games directly from the device.

Wiring diagram

Assemble it in 4 steps

1. Cihazı USB ile besleyin

NM-TV-154 cihazını veri taşıyabilen bir USB kablosuyla bilgisayara takın. Bu sürüm cihazın kendi ekranını ve üst taraftaki dokunmatik alanı kullanır; haricî parça ya da kablo takılmaz.

  • Şarj amaçlı bazı USB kablolar veri taşımaz; Deploy cihazı görmezse başka bir USB kablosu deneyin.
  • Cihazın kasasını açmayın ve içteki ekran bağlantılarına dokunmayın; yanlış temas ekranın çalışmamasına yol açabilir.

2. Ekrandaki IP adresini açın

Cihaz açıldığında önce kayıtlı Wi‑Fi ağına bağlanmayı dener. Ekranda Wi‑Fi BAGLANDI ve örneğin IP: 192.168.1.42 yazdığında telefon veya bilgisayarınızda tarayıcıya bu sayıyı yazın. Wi‑Fi ağı bulunamazsa telefonunuzu NM-TV-GB ağına bağlayın; parola gameboy32, ardından ekrandaki IP adresini tarayıcıya yazın.

  • Telefon ve cihaz aynı ev Wi‑Fi ağına bağlıysa ekrandaki IP adresiyle yükleme sayfası açılır.
  • IP adresi her açılışta değişebilir; doğru olan her zaman ekranda görünen adrestir.
  • Yükleme sayfası yalnızca cihazın bağlı olduğu yerel ağda çalışır; IP adresini internette herkese açık bir yere paylaşmayın.

3. Yeni Game Boy ROM’unu yükleyin

Tarayıcıdaki sayfada size ait yedek veya açık lisanslı .gb ya da .gbc ROM dosyasını seçin ve ROM'u yükle ve başlat düğmesine basın. Dosya cihazın dahili belleğine kaydedilir; yükleme tamamlanınca oyun otomatik olarak başlar ve sonraki açılışlarda aynı ROM kullanılır.

  • Yeni bir ROM yüklemek eskisinin yerini alır.
  • 1 MB veya daha küçük ROM dosyası kullanın.
  • Yükleme sırasında USB gücünü kesmeyin; kesilirse ROM dosyası bozulabilir ve yeniden yüklemeniz gerekir.

4. Oyunu başlatın veya ROM değiştirme ekranına dönün

Kayıtlı ROM varsa cihaz Wi‑Fi bağlantısından sonra oyunu kendisi başlatır. Üst taraftaki dokunmatik alana kısa dokunmak Game Boy START tuşu gibi çalışır. Oyun açıkken aynı alana yaklaşık 5 saniye boyunca basılı tutun; cihaz ROM YUKLE ekranına döner ve yeni IP adresini gösterir.

  • Game Boy görüntüsü 160×144 olduğundan kare ekranın çevresinde siyah alan kalması normaldir.
  • Oyunun kendi tanıtım döngüsü varsa ek düğme olmadan da ekranda hareket eder.
  • Bu denemede yön, A ve B tuşları yoktur; oyun normal oynanış için değil, ROM’un açılışını ve varsa demo akışını görmek içindir.

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <LittleFS.h>
#include <TFT_eSPI.h>
// This is a monochrome DMG frontend; skip Peanut-GB's extra per-pixel
// palette/layer bookkeeping to leave more CPU time for the emulator.
#define PEANUT_GB_12_COLOUR 0
#define PEANUT_GB_HIGH_LCD_ACCURACY 0
#include "deneme.h"


struct RomState {
  File file;
  size_t bytes;
  uint32_t cacheStart;
  size_t cacheBytes;
  uint8_t cache[4096];
};


// Forward declarations

// Forward declarations
void presentFrame(const uint16_t *frame);

void showScreen(const String &title, const String &detail, uint16_t color);
String currentIp();
void showUploadScreen(const String &note);
size_t maximumRomBytes();
bool validRomFile(const char *path);
bool makeActiveRom();
uint8_t readRom(gb_s *context, const uint_fast32_t address);
uint8_t readCartRam(gb_s *, const uint_fast32_t);
void writeCartRam(gb_s *, const uint_fast32_t, const uint8_t);
void emulatorError(gb_s *, const enum gb_error_e error, const uint16_t address);
void drawGbLine(gb_s *, const uint8_t *pixels, const uint_fast8_t line);
void closeEmulator();
bool loadEmulator(const char *path);
void startGame();
void handleUpload();
void handleUploadDone();
bool downloadToTemp(const String &url);
void handleUrlInstall();
bool ensureDefaultRom();
void connectWiFi();

constexpr int LCD_POWER_PIN = 21;
constexpr int LCD_BACKLIGHT_PIN = 19;
constexpr int TOUCH_START_PIN = 32;
constexpr uint16_t TOUCH_MARGIN = 80;
constexpr uint32_t LONG_PRESS_MS = 5000;
constexpr size_t ROM_CACHE_BYTES = 4096;
constexpr size_t ROM_HARD_LIMIT = 2U * 1024U * 1024U;
constexpr char ACTIVE_ROM_PATH[] = "/game.gb";
constexpr char DEFAULT_ROM_PATH[] = "/bubble_ghost.gb";
constexpr char TEMP_ROM_PATH[] = "/incoming.gb";
constexpr char DEFAULT_ROM_URL[] = "https://archive.org/download/vvv18.gb/GB-GAME%20BOY/B-ROMS/Bubble%20Ghost%20%28USA%2C%20Europe%29.gb";
constexpr char AP_NAME[] = "NM-TV-GB";
constexpr char AP_PASSWORD[] = "gameboy32";



TFT_eSPI tft;
WebServer server(80);
gb_s gb;
RomState romState = {};
uint16_t touchIdle = 0;
bool emulating = false;
bool uploadOk = false;
uint32_t pressStarted = 0;
uint32_t lastEmulationMillis = 0;
// The 240x216 scaled image is 103,680 bytes. Sending it 30 times per
// second monopolises the CPU/SPI bus and was making the Game Boy itself run
// in slow motion. Start with a deliberately light 10 Hz presenter: emulation
// still advances at every Game Boy frame, while the panel shows the newest
// completed image at this cadence.
constexpr uint32_t PRESENT_INTERVAL_MS = 100;
uint32_t lastPresentMillis = 0;
// schematik-debug: temporary FPS instrumentation; remove after root-cause measurement.
uint32_t perfWindowStarted = 0;
uint16_t emulatedFramesInWindow = 0;
uint16_t displayedFramesInWindow = 0;
String lastUploadError;
// One native 160x144 RGB565 framebuffer. Keeping all Peanut-GB and TFT work
// in Arduino's main task avoids the Core 1 memory corruption seen with the
// experimental cross-task presentation path.
uint16_t *frameBuffer = nullptr;
// Kept outside the task stack. One 12-line strip lets the LCD receive each
// scaled frame in 18 transfers rather than 216 separate one-line transfers.
constexpr uint16_t LCD_STRIP_LINES = 12;
uint16_t scaledStrip[240 * LCD_STRIP_LINES];
size_t uploadBytes = 0;
size_t uploadLimitBytes = 0;
bool uploadWriteFailed = false;
// Standard RGB565 Game Boy Pocket-style shades. The NM-TV-154 ST7789 panel
// requires display inversion at initialisation; do not byte-swap these words.
const uint16_t palette[4] = {0xD7E8, 0x9DB6, 0x5A6B, 0x1A2D};

void showScreen(const String &title, const String &detail, uint16_t color) {
  Serial.printf("DISPLAY: %s - %s\n", title.c_str(), detail.c_str());
  tft.fillScreen(TFT_BLACK);
  tft.drawRoundRect(8, 8, 224, 224, 12, color);
  tft.drawFastHLine(20, 62, 200, color);
  tft.setTextDatum(TC_DATUM);
  tft.setTextFont(2);
  tft.setTextColor(color, TFT_BLACK);
  tft.drawString(title, 120, 28);
  tft.setTextFont(1);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.drawCentreString(detail, 120, 92, 1);
  tft.setTextDatum(TL_DATUM);
}

String currentIp() {
  return WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : WiFi.softAPIP().toString();
}

void showUploadScreen(const String &note = "ROM secin veya URL girin") {
  emulating = false;
  // Release /game.gb before a replacement is written to LittleFS.
  closeEmulator();
  showScreen("ROM YUKLE", "Tarayicida: " + currentIp(), TFT_CYAN);
  tft.setTextDatum(TC_DATUM);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);
  tft.drawCentreString(note, 120, 120, 1);
  tft.drawCentreString("Yukleme bitince oyun baslar", 120, 136, 1);
  tft.setTextDatum(TL_DATUM);
}

size_t maximumRomBytes() {
  // The incoming file is written after the current custom ROM is removed;
  // the small default ROM stays available as the safe fallback.
  const size_t freeBytes = LittleFS.totalBytes() - LittleFS.usedBytes();
  return min(ROM_HARD_LIMIT, freeBytes > 4096 ? freeBytes - 4096 : size_t(0));
}

bool validRomFile(const char *path) {
  File in = LittleFS.open(path, "r");
  if (!in) return false;
  const size_t bytes = in.size();
  // Capacity is enforced while receiving a new file. An already saved ROM
  // must not become "invalid" merely because it itself occupies LittleFS.
  if (bytes < 0x150 || bytes > ROM_HARD_LIMIT) { in.close(); return false; }
  uint8_t sum = 0;
  for (uint16_t address = 0x134; address <= 0x14C; ++address) {
    if (!in.seek(address, SeekSet)) { in.close(); return false; }
    int value = in.read();
    if (value < 0) { in.close(); return false; }
    sum = uint8_t(sum - uint8_t(value) - 1);
  }
  in.seek(0x14D, SeekSet);
  int checksum = in.read();
  in.close();
  return checksum >= 0 && sum == uint8_t(checksum);
}

bool makeActiveRom() {
  if (!validRomFile(TEMP_ROM_PATH)) {
    lastUploadError = "ROM basligi veya kontrol toplami gecersiz";
    Serial.println("ROM dogrulama basarisiz: baslik kontrol toplami eslesmedi");
    LittleFS.remove(TEMP_ROM_PATH);
    return false;
  }
  LittleFS.remove(ACTIVE_ROM_PATH);
  if (!LittleFS.rename(TEMP_ROM_PATH, ACTIVE_ROM_PATH)) {
    lastUploadError = "Dahili bellek dosyayi etkinlestiremedi";
    Serial.println("ROM etkinlestirme basarisiz: incoming.gb game.gb olarak yeniden adlandirilamadi");
    LittleFS.remove(TEMP_ROM_PATH);
    return false;
  }
  lastUploadError = "";
  return true;
}

uint8_t readRom(gb_s *context, const uint_fast32_t address) {
  RomState *state = static_cast<RomState *>(context->direct.priv);
  if (address >= state->bytes) return 0xFF;
  if (address < state->cacheStart || address >= state->cacheStart + state->cacheBytes) {
    state->cacheStart = uint32_t(address & ~(ROM_CACHE_BYTES - 1));
    state->file.seek(state->cacheStart, SeekSet);
    const size_t remaining = state->bytes - state->cacheStart;
    const size_t readBytes = remaining < ROM_CACHE_BYTES ? remaining : ROM_CACHE_BYTES;
    state->cacheBytes = state->file.read(state->cache, readBytes);
    if (!state->cacheBytes) return 0xFF;
  }
  return state->cache[address - state->cacheStart];
}
uint8_t readCartRam(gb_s *, const uint_fast32_t) { return 0xFF; }
void writeCartRam(gb_s *, const uint_fast32_t, const uint8_t) {}
void emulatorError(gb_s *, const enum gb_error_e error, const uint16_t address) {
  Serial.printf("Emulator hatasi: %d @ %04X\n", int(error), address);
}
void drawGbLine(gb_s *, const uint8_t *pixels, const uint_fast8_t line) {
  // Peanut-GB also signals VBlank as line 144; only 0..143 are visible.
  if (line >= 144 || !frameBuffer) return;
  uint16_t *row = &frameBuffer[uint16_t(line) * 160U];
  for (uint16_t x = 0; x < 160; ++x) row[x] = palette[pixels[x] & 3];
}

void presentFrame(const uint16_t *frame) {
  // 160x144 -> 240x216 is exact 3:2 nearest-neighbour scaling. Peanut-GB
  // completes a whole frame before this function reads it, so one framebuffer
  // is sufficient and avoids the previous cross-task crash.
  tft.startWrite();
  for (uint16_t y = 0; y < 216; y += LCD_STRIP_LINES) {
    for (uint16_t row = 0; row < LCD_STRIP_LINES; ++row) {
      const uint16_t *source = &frame[((uint32_t(y + row) * 2U) / 3U) * 160U];
      uint16_t *destination = &scaledStrip[row * 240U];
      for (uint16_t dx = 0; dx < 240; ++dx) {
        destination[dx] = source[(uint32_t(dx) * 2U) / 3U];
      }
    }
    tft.setAddrWindow(0, y + 12, 240, LCD_STRIP_LINES);
    // TFT_eSPI stores uint16_t pixels in CPU byte order. This argument must
    // be true so each RGB565 word is sent MSB first; false produced the
    // photographed pink/magenta byte-swapped colours.
    tft.pushColors(scaledStrip, 240U * LCD_STRIP_LINES, true);
  }
  tft.endWrite();
}


void closeEmulator() {
  if (romState.file) romState.file.close();
  romState = {};
}

bool loadEmulator(const char *path) {
  closeEmulator();
  if (!validRomFile(path)) return false;
  romState.file = LittleFS.open(path, "r");
  if (!romState.file) return false;
  romState.bytes = romState.file.size();
  if (gb_init(&gb, readRom, readCartRam, writeCartRam, emulatorError, &romState) != GB_INIT_NO_ERROR) {
    closeEmulator();
    return false;
  }
  gb_init_lcd(&gb, drawGbLine);
  // Still emulate every frame. The panel is updated at a lower cadence so
  // its SPI transfer cannot make the game clock run in slow motion.
  // Emulate every Game Boy frame, but have Peanut render alternate frames.
  // This cuts LCD callback work in half without slowing the emulated clock.
  gb.direct.frame_skip = true;
  gb.direct.joypad = 0xFF;
  return true;
}

void startGame() {
  showScreen("OYUN BASLIYOR", "Kayitli ROM aciliyor", TFT_GREEN);
  const char *path = validRomFile(ACTIVE_ROM_PATH) ? ACTIVE_ROM_PATH : DEFAULT_ROM_PATH;
  if (!loadEmulator(path)) { showUploadScreen("Gecerli ROM bulunamadi"); return; }
  Serial.printf("Emulator basladi: %s (%u bayt)\n", path, unsigned(romState.bytes));
  tft.fillScreen(TFT_BLACK);
  emulating = true;
  lastEmulationMillis = millis();
  lastPresentMillis = 0;
  perfWindowStarted = millis();
  emulatedFramesInWindow = 0;
  displayedFramesInWindow = 0;
}

const char UPLOAD_PAGE[] PROGMEM = R"HTML(<!doctype html><html lang="tr"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NM-TV Game Boy</title><body style="font-family:sans-serif;max-width:36em;margin:2em auto;line-height:1.45"><h2>Game Boy ROM ekle</h2><p>Yeni oyun kalıcı olarak kaydedilir ve cihaz onu hemen başlatır. Bozuk dosya yüklenirse Bubble Ghost varsayılan oyun olarak korunur.</p><h3>Dosyadan yükle</h3><form method="POST" action="/upload" enctype="multipart/form-data"><input type="file" name="rom" accept=".gb,.gbc" required><p><button>ROM'u yükle ve başlat</button></p></form><h3>İnternet bağlantısından indir</h3><form method="POST" action="/url"><input style="width:100%" type="url" name="url" placeholder="https://.../oyun.gb" required><p><button>URL'den indir ve başlat</button></p></form><p>En fazla yaklaşık 2 MB; gerçek sınır cihazın boş dahili belleğidir.</p></body></html>)HTML";

void handleUpload() {
  HTTPUpload &upload = server.upload();
  static File output;
  if (upload.status == UPLOAD_FILE_START) {
    // A game may still have /game.gb open. Close it and reclaim that space
    // before receiving the replacement; Bubble Ghost remains the fallback.
    emulating = false;
    closeEmulator();
    LittleFS.remove(ACTIVE_ROM_PATH);
    uploadOk = false;
    lastUploadError = "";
    uploadBytes = 0;
    uploadLimitBytes = maximumRomBytes();
    uploadWriteFailed = (uploadLimitBytes < 0x150);
    if (uploadWriteFailed) lastUploadError = "Dahili bellekte yeterli yer yok";
    LittleFS.remove(TEMP_ROM_PATH);
    output = LittleFS.open(TEMP_ROM_PATH, "w");
    if (!output) { uploadWriteFailed = true; lastUploadError = "Gecici ROM dosyasi acilamadi"; }
    Serial.printf("Dosya yukleme basladi: %s, sinir: %u bayt\n", upload.filename.c_str(), unsigned(uploadLimitBytes));
  } else if (upload.status == UPLOAD_FILE_WRITE) {
    if (uploadWriteFailed || uploadBytes + upload.currentSize > uploadLimitBytes ||
        !output || output.write(upload.buf, upload.currentSize) != upload.currentSize) {
      if (lastUploadError.length() == 0) lastUploadError =
        (uploadBytes + upload.currentSize > uploadLimitBytes) ? "ROM bos bellekten buyuk" : "Dahili bellege yazma hatasi";
      uploadWriteFailed = true;
    } else {
      uploadBytes += upload.currentSize;
    }
  } else if (upload.status == UPLOAD_FILE_END) {
    if (output) output.close();
    if (!uploadWriteFailed && uploadBytes < 0x150) {
      uploadWriteFailed = true;
      lastUploadError = "Dosya Game Boy ROM'u olmak icin cok kucuk";
    }
    uploadOk = !uploadWriteFailed && makeActiveRom();
    if (!uploadOk) LittleFS.remove(TEMP_ROM_PATH);
    Serial.printf("Dosya yukleme bitti: %u bayt, %s%s%s\n", unsigned(uploadBytes),
                  uploadOk ? "ok" : "hata", uploadOk ? "" : ": ",
                  uploadOk ? "" : lastUploadError.c_str());
  }
}

void handleUploadDone() {
  if (!uploadOk) {
    const String message = "ROM yuklenemedi: " + (lastUploadError.length() ? lastUploadError : "bilinmeyen hata") + ". Varsayilan oyun korunuyor.";
    server.send(400, "text/plain; charset=utf-8", message);
    return;
  }
  server.send(200, "text/html; charset=utf-8", "<h2>ROM kaydedildi.</h2><p>Cihaz oyunu baslatiyor.</p>");
  delay(250); startGame();
}

bool downloadToTemp(const String &url) {
  if (!url.startsWith("http://") && !url.startsWith("https://")) return false;
  HTTPClient http;
  http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  http.setTimeout(15000);
  if (!http.begin(url)) return false;
  const int result = http.GET();
  if (result != HTTP_CODE_OK) { Serial.printf("HTTP hata: %d\n", result); http.end(); return false; }
  const int contentLength = http.getSize();
  const size_t downloadLimitBytes = maximumRomBytes();
  if (contentLength > 0 && size_t(contentLength) > downloadLimitBytes) { http.end(); return false; }
  LittleFS.remove(TEMP_ROM_PATH);
  File out = LittleFS.open(TEMP_ROM_PATH, "w");
  if (!out) { http.end(); return false; }
  WiFiClient *stream = http.getStreamPtr();
  size_t written = 0;
  uint8_t buffer[512];
  uint32_t lastData = millis();
  while (http.connected() && (contentLength < 0 || written < size_t(contentLength))) {
    const size_t available = stream->available();
    if (!available) { if (millis() - lastData > 20000) break; delay(1); continue; }
    const size_t take = min(available, sizeof(buffer));
    const size_t got = stream->readBytes(buffer, take);
    if (!got) continue;
    lastData = millis();
    if (written + got > downloadLimitBytes || out.write(buffer, got) != got) { out.close(); LittleFS.remove(TEMP_ROM_PATH); http.end(); return false; }
    written += got;
  }
  out.close(); http.end();
  Serial.printf("URL indirme bitti: %u bayt\n", unsigned(written));
  return contentLength < 0 || written == size_t(contentLength);
}

void handleUrlInstall() {
  // Same replacement policy as browser upload: close the running ROM and
  // keep /bubble_ghost.gb as the safe fallback if the download is invalid.
  emulating = false;
  closeEmulator();
  LittleFS.remove(ACTIVE_ROM_PATH);
  const String url = server.arg("url");
  showScreen("ROM INIYOR", "URL'den indiriliyor", TFT_YELLOW);
  const bool downloaded = downloadToTemp(url);
  uploadOk = downloaded && makeActiveRom();
  if (!uploadOk) { server.send(400, "text/plain; charset=utf-8", "URL indirilemedi veya Game Boy ROM'u gecersiz. Varsayilan oyun korunuyor."); showUploadScreen("URL veya ROM dosyasi deneyin"); return; }
  server.send(200, "text/html; charset=utf-8", "<h2>ROM kaydedildi.</h2><p>Cihaz oyunu baslatiyor.</p>");
  delay(250); startGame();
}

bool ensureDefaultRom() {
  if (validRomFile(DEFAULT_ROM_PATH)) return true;
  if (WiFi.status() != WL_CONNECTED) return false;
  showScreen("VARSAYILAN OYUN", "Bubble Ghost indiriliyor", TFT_YELLOW);
  if (!downloadToTemp(DEFAULT_ROM_URL) || !validRomFile(TEMP_ROM_PATH)) { LittleFS.remove(TEMP_ROM_PATH); return false; }
  LittleFS.remove(DEFAULT_ROM_PATH);
  return LittleFS.rename(TEMP_ROM_PATH, DEFAULT_ROM_PATH);
}

void connectWiFi() {
  showScreen("Wi-Fi", "Kayitli aga baglaniliyor", TFT_YELLOW);
  WiFi.mode(WIFI_STA); WiFi.begin();
  const uint32_t began = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - began < 15000) delay(100);
  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("Wi-Fi baglandi, IP: %s\n", WiFi.localIP().toString().c_str());
    showScreen("Wi-Fi BAGLANDI", "IP: " + WiFi.localIP().toString(), TFT_GREEN);
  } else {
    WiFi.mode(WIFI_AP); WiFi.softAP(AP_NAME, AP_PASSWORD);
    Serial.printf("Wi-Fi AP acildi, IP: %s\n", WiFi.softAPIP().toString().c_str());
    showScreen("Wi-Fi YOK", "NM-TV-GB | IP: " + WiFi.softAPIP().toString(), TFT_YELLOW);
  }
  delay(1600);
}

void setup() {
  Serial.begin(115200); delay(250);
  pinMode(LCD_POWER_PIN, OUTPUT); pinMode(LCD_BACKLIGHT_PIN, OUTPUT);
  digitalWrite(LCD_POWER_PIN, LOW); digitalWrite(LCD_BACKLIGHT_PIN, LOW);
  tft.init(); tft.setRotation(0);
  tft.setSwapBytes(false);
  // NM-TV-154's ST7789 panel needs this controller mode for correct colour.
  tft.invertDisplay(true);
  touchIdle = touchRead(TOUCH_START_PIN);
  if (!LittleFS.begin(true)) { showScreen("HATA", "Dahili bellek acilamadi", TFT_RED); return; }
  frameBuffer = static_cast<uint16_t *>(heap_caps_malloc(160UL * 144UL * sizeof(uint16_t), MALLOC_CAP_8BIT));
  if (!frameBuffer) { showScreen("HATA", "Ekran bellegi yok", TFT_RED); return; }
  memset(frameBuffer, 0, 160UL * 144UL * sizeof(uint16_t));
  server.on("/", HTTP_GET, []() { server.send_P(200, "text/html; charset=utf-8", UPLOAD_PAGE); });
  server.on("/upload", HTTP_POST, handleUploadDone, handleUpload);
  server.on("/url", HTTP_POST, handleUrlInstall);
  // The network interface must exist before WebServer creates its listener.
  // Starting it earlier triggers a FreeRTOS semaphore assertion on this ESP32.
  connectWiFi();
  server.begin();
  Serial.printf("Web sunucusu hazir: http://%s/\n", currentIp().c_str());
  const bool defaultOk = ensureDefaultRom();
  if (!defaultOk) Serial.println("Varsayilan ROM henuz yok");
  if (validRomFile(ACTIVE_ROM_PATH) || defaultOk) startGame(); else showUploadScreen("Once Wi-Fi ile varsayilani indirin");
}

void loop() {
  server.handleClient();
  const uint16_t value = touchRead(TOUCH_START_PIN);
  const bool pressed = value + TOUCH_MARGIN < touchIdle;
  if (!pressed) { touchIdle = (touchIdle * 15 + value) / 16; pressStarted = 0; }
  else if (!pressStarted) pressStarted = millis();
  if (emulating) {
    gb.direct.joypad = pressed ? uint8_t(0xFF & ~JOYPAD_START) : 0xFF;
    if (pressed && millis() - pressStarted >= LONG_PRESS_MS) {
      gb.direct.joypad = 0xFF;
      showUploadScreen("Kisa dokunma: START | 5 sn: menu");
      while (touchRead(TOUCH_START_PIN) + TOUCH_MARGIN < touchIdle) { server.handleClient(); delay(20); }
      pressStarted = 0;
      return;
    }
    // Always advance the emulated Game Boy. Display transfer is separately
    // rate-limited so it cannot dictate game speed.
    gb_run_frame(&gb);
    ++emulatedFramesInWindow;
    const uint32_t now = millis();
    // Peanut renders every other frame when frame_skip is enabled; present
    // only the newly rendered frame, and only when the LCD interval expires.
    if (!gb.display.frame_skip_count && now - lastPresentMillis >= PRESENT_INTERVAL_MS) {
      presentFrame(frameBuffer);
      lastPresentMillis = millis();
      ++displayedFramesInWindow;
    }
    if (now - perfWindowStarted >= 1000) {
      Serial.printf("PERF: emu=%u fps, lcd=%u fps\n", emulatedFramesInWindow, displayedFramesInWindow);
      perfWindowStarted = now;
      emulatedFramesInWindow = 0;
      displayedFramesInWindow = 0;
    }
  }
  delay(1);
}

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