Ryan McDonough

Founder, Sometime Artist

CFO and co-founder @Accompany, acquired by @Cisco. Turnaround CFO @Ning, sold to Glam Media. Former seed VC. McKinsey trained. @Wharton School and @Haas School of Business.

Follow

TINKERING

Modding LEGO’s Game Boy (72046) with an M5Stack Core2

LEGO’s new Game Boy kit (72046) is designed to sit pretty on a shelf. I couldn’t resist turning it into a working nostalgia machine.
GAME BOY MOD

Behind the Build: Modding LEGO’s Game Boy (72046) with an M5Stack Core2 LEGO’s new Game Boy kit (72046) is a nostalgia bomb — but like most of LEGO’s official retro builds, it’s designed to sit on a shelf, not actually do anything.

 

I decided to fix that by shoehorning an M5Stack Core2 (an ESP32 touchscreen device with buttons, speaker, Wi-Fi, and SD support) into the shell. The result? A LEGO Game Boy that boots with the classic Nintendo splash, plays a chime, shows a slideshow of retro images, and doubles as a Wi-Fi clock.

 
This post isn’t a polished tutorial — it’s a behind-the-scenes build log with the good, the bad, and the hacky.

LEGO Mods

  • Interior clearance → removed LEGO’s internal attachment hooks to fit the Core2.
  • Cartridge hack → used Command Strips to attach a modified LEGO cartridge to the Core2.
  • Screen access → popped out the transparent window so the touchscreen was usable.

 

From the outside, it looks like the stock LEGO kit. Inside, it’s been gutted and rebuilt.

 

Coding the Core2 (a.k.a. the rabbit hole)

This project started with the Arduino IDE and quickly spiraled:

 

  • Wrestling with M5Unified and M5GFX libraries.
  • Endless compile errors: image decoding mismatches, orientation quirks (buttons upside-down), gray bars, freezes.
  • GIF playback disasters — vertical lines, crashes, broken color palettes.
  • Solution: frame-by-frame boot animation at 25 FPS, plus slideshow mode for static images.
  • Added a boot chime using the Core2 speaker to mimic Nintendo’s rising sweep.
  • Wi-Fi + NTP gave me real date/time, displayed in oversized pixel fonts.

 

It was messy, but each step made it feel more like a working Game Boy.

Boot Sequence Flow

  1. Nintendo animation (frame pack on SD).
  2. Boot chime from speaker.
  3. Falls back into slideshow mode (retro screenshots, box art).
  4. Long-press toggles to clock mode with date/time.

It doesn’t play actual games (yet), but the vibe is spot-on.

Pain Points & Fixes

  • Hidden Mac files → every SD card copy added .DS_Store junk, breaking image scans
  • GIF rendering → abandoned animated GIFs, went with frame packs.
  • Sound tuning → simple 8-bit sweep was enough to capture the nostalgia.
  • Time zones → Wi-Fi worked, but time offset required manual NTP + timezone handling.

Screen Grabs

Future Ideas

  • Add a lightweight video player (old commercials, pixel loops).
  • Use the accelerometer for playful mini-games.
  • Layer in more sound packs for button presses or startup variations.
  • Link it with my other LEGO nostalgia machines for a full “living museum shelf.”

Lessons Learned

  • LEGO is surprisingly mod-friendly — a few removed hooks and it swallows custom hardware.
  • The M5Stack Core2 is powerful but finicky — every library update is a landmine.
  • Debugging is 50% of the fun (and 90% of the frustration).

Final Notes

This wasn’t about making the best emulator or the cleanest mod. It was about capturing that feeling of flipping on a Game Boy, hearing a chime, and watching a screen come to life.

 

LEGO gave me the nostalgia shell. The Core2 gave me the spark.

📎 Code & Tools

Arduino IDE Setup (for M5Stack Core2)

If you’re starting from a clean install, here’s what I had to do:

Install the Arduino IDE

  • Grab the latest from arduino.cc.
  • On Mac, drag it into Applications like any other app.

Add ESP32 board support

  • Open Preferences → add this to Additional Board URLs:
    https://espressif.github.io/arduino-esp32/package_esp32_index.json
  • Go to Tools → Board → Board Manager and install ESP32 by Espressif Systems (tested with version 3.3.1).

Select the right board

  • Tools → Board → ESP32 Arduino → M5Stack-Core2.
  • Tools → Port → pick the /dev/cu.usbserial-xxxx device that appears when you plug in the Core2.

Install required libraries

  • Use Tools → Manage Libraries to install the required ones:
    • M5Unified
    • M5GFX
    • TJpg_Decoder
    • ESP8266Audio
    • etc.

First upload test

  • Run the example sketch: M5Unified → Basics → HelloWorld.
  • Make sure text appears on the Core2’s screen.
  • If you see a white screen or a crash, double-check the board selection and libraries.

Common gotchas

  • If the upload fails with port busy, unplug/replug USB or check that no other app (like Serial Monitor) has the port open.
  • On Mac, .DS_Store files sneak into your SD card → delete them or use a cleanup script before testing media.
  • Make sure the Core2 is powered on when flashing (button on the left side).
Core Arduino Board Support
  • ESP32 Board Support
  • Install via Arduino IDE → Board Manager
  • Search for: esp32 by Espressif Systems
  • Version tested: 3.3.1

 

Libraries
  • M5Stack Ecosystem
    • M5Unified (by M5Stack) – All-in-one library for Core2 display, touch, speaker, RTC, etc.
    • M5GFX (by M5Stack) – Required by M5Unified for graphics.
  • Image Handling
    • TJpg_Decoder (by Bodmer) – For decoding JPEGs from SD into the display buffer.
  • SD Card Support
    • SD (built into ESP32 core) – Already included with ESP32 package; ensure you use the ESP32 version (not the Arduino AVR one).
  • Networking / Time
    • WiFi (built into ESP32 core)
    • WiFiClient (bundled with ESP32 core)
    • time.h (bundled with ESP32 core) – Used for NTP syncing and timezone handling.
  • Audio (for boot chime / WAV files if expanded later)
    • ESP8266Audio (by Earle Philhower) – Provides AudioFileSourceSD, AudioGeneratorWAV, and AudioOutputI2S. Works on ESP32 despite the name.

 

Optional
  • AnimatedGIF (by Larry Bank) – Tested but scrapped in favor of frame packs. Can be omitted unless you want to try GIFs.
TERMINAL SCRIPT FOR REMOVING HIDDEN .IMAGE FILES

Replace ‘SDCARD’ with the name of your volume, e.g., GAMEBOY or something similar.

				
					# Remove Finder junk
find /Volumes/SDCARD/media -name ".DS_Store" -delete
find /Volumes/SDCARD/media -name "._*" -delete
dot_clean -m /Volumes/SDCARD/media

				
			
Final Arduino sketch

I used the Arduino IDE. 

				
					/*
========================================================
  LEGO Game Boy — M5Stack Core2
  Big Clock + NTP (Pacific), Boot Chime, 25 FPS Boot Frames,
  Gallery/Slideshow, Dotfile Filter
  + Serial & On-screen Wi-Fi/NTP Debug
========================================================

Controls (top invisible touch bar, 60 px tall):
• In Gallery:
    Left          = Previous image
    Middle short  = Toggle slideshow
    Middle long   = Switch to Clock
    Right         = Next image
• In Clock:
    Left          = 12/24h toggle
    Middle        = Back to Gallery
    Right         = Palette swap (GB look)
*/

#include <M5Unified.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <vector>
#include <algorithm>
#include <time.h>
#include "esp_heap_caps.h"

// ------------------- EDIT ME -------------------
static const char* WIFI_SSID = "YOUR_WIFI_SSID";
static const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";

// Timezone: America/Los_Angeles with DST (PST/PDT)
static const char* TZ_PACIFIC = "PST8PDT,M3.2.0/2,M11.1.0/2";

// ------------------- PATHS & CONFIG -------------------
static const char* MEDIA_DIR   = "/media";
static const char* BOOT_DIR    = "/boot";
static const int   TOUCH_BAR_H = 60;

static const uint32_t SLIDE_INTERVAL_MS = 8000; // slideshow cadence
static const uint32_t LONG_PRESS_MS     = 800;  // long press for mode switch
static const uint32_t BOOT_FPS          = 25;   // boot animation FPS

// Colors (RGB565)
static const uint16_t COL_BLACK = 0x0000;
static const uint16_t COL_WHITE = 0xFFFF;
static const uint16_t GB_DARK   = 0x31A6;
static const uint16_t GB_LITE   = 0x8C92;
static const uint16_t GB_PAPER  = 0xBDF7;

// ------------------- STATE -------------------
enum Mode { MODE_GALLERY, MODE_CLOCK };
Mode currentMode = MODE_GALLERY;

std::vector<String> files;   // gallery images
int idx = 0;

bool use24h       = false;
bool useGBPalette = true;

bool slideshowOn  = false;
uint32_t lastSlideMs = 0;

bool barDown = false;
uint32_t barDownAt = 0;
int barBtn = 0;

// ========================================================
//   OPTIONAL: Offline manual date/time set
//   Call once (then comment out) to set RTC without Wi-Fi.
//   Example (Sept 30, 2025 17:21:00):
//   setRTC(2025, 9, 30, 17, 21, 0, TZ_PACIFIC);
// ========================================================
void setRTC(int year, int month, int day, int hour, int min, int sec, const char* tzName) {
  setenv("TZ", tzName, 1);
  tzset();

  struct tm t = {};
  t.tm_year = year - 1900;
  t.tm_mon  = month - 1;
  t.tm_mday = day;
  t.tm_hour = hour;
  t.tm_min  = min;
  t.tm_sec  = sec;

  time_t now = mktime(&t);          // interprets as local time per TZ
  struct timeval tv = { now, 0 };
  settimeofday(&tv, nullptr);
}

// ========================================================
//   FILE FILTERING (skip macOS junk; accept only JPEG)
// ========================================================
static bool isImageFile(const String& name) {
  String n = name; n.toLowerCase();
  if (n.startsWith(".") || n.startsWith("._")) return false; // hidden / AppleDouble
  return n.endsWith(".jpg") || n.endsWith(".jpeg");
}

std::vector<String> listImagesIn(const char* dir) {
  std::vector<String> list;
  File root = SD.open(dir);
  if (!root || !root.isDirectory()) return list;

  File f;
  while ((f = root.openNextFile())) {
    if (f.isDirectory()) { f.close(); continue; }
    String name = String(f.name());
    if (!isImageFile(name)) { f.close(); continue; }
    list.push_back(String(dir) + "/" + name);
    f.close();
  }
  std::sort(list.begin(), list.end(),
            [](const String& a, const String& b){ return a.compareTo(b) < 0; });
  return list;
}

// ========================================================
//   IMAGE RENDERING (buffered JPEG draw)
// ========================================================
bool drawJpgBuffered(const String& path, int x, int y){
  File f = SD.open(path, FILE_READ);
  if (!f) return false;

  size_t len = f.size();
  if (!len) { f.close(); return false; }

  uint8_t* buf = (uint8_t*)heap_caps_malloc(len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  if (!buf) buf = (uint8_t*)malloc(len);
  if (!buf) { f.close(); return false; }

  size_t off = 0;
  while (off < len) {
    int r = f.read(buf + off, len - off);
    if (r <= 0) break;
    off += r;
  }
  f.close();

  bool ok = (off == len) && M5.Display.drawJpg(buf, len, x, y);
  free(buf);
  return ok;
}

void clearBG(){ M5.Display.fillScreen(useGBPalette ? GB_PAPER : COL_BLACK); }

void showImage(const String& path){
  clearBG();
  if (!drawJpgBuffered(path, 0, 0)) {
    M5.Display.setTextDatum(middle_center);
    M5.Display.setTextColor(COL_WHITE, COL_BLACK);
    M5.Display.drawString("Image failed", M5.Display.width()/2, M5.Display.height()/2);
  }
}

void showCurrent(){ if (!files.empty()) showImage(files[idx]); else clearBG(); lastSlideMs = millis(); }
void nextItem(int d){ if (files.empty()) return; idx = (idx + d + files.size()) % files.size(); showCurrent(); }

// ========================================================
//   WIFI + NTP (Pacific) — with Serial & on-screen debug
// ========================================================
bool syncTimeViaWiFi() {
  Serial.println("[WiFi] Starting connect");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  // On-screen status
  M5.Display.fillScreen(COL_BLACK);
  M5.Display.setTextDatum(middle_center);
  M5.Display.setTextSize(1);
  M5.Display.setTextColor(COL_WHITE, COL_BLACK);
  M5.Display.drawString("Connecting Wi-Fi…", M5.Display.width()/2, M5.Display.height()/2 - 14);

  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 12000) {
    delay(200);
    M5.Display.drawString(".", M5.Display.width()/2, M5.Display.height()/2 + 6);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[WiFi] FAILED");
    M5.Display.drawString("Wi-Fi FAILED", M5.Display.width()/2, M5.Display.height()/2 + 26);
    WiFi.mode(WIFI_OFF);
    return false;  // offline, keep RTC as-is
  }

  IPAddress ip = WiFi.localIP();
  Serial.printf("[WiFi] Connected. IP: %s\n", ip.toString().c_str());
  M5.Display.drawString("Wi-Fi OK", M5.Display.width()/2, M5.Display.height()/2 + 26);

  // Set TZ and start SNTP in one call (handles DST correctly)
  Serial.println("[NTP] configTzTime -> Pacific");
  configTzTime(TZ_PACIFIC, "pool.ntp.org", "time.nist.gov");

  // Wait for a sane time (cap ~6s)
  time_t now = 0;
  uint32_t t1 = millis();
  do {
    delay(250);
    time(&now);
  } while ((now < 1700000000 /* ~2023-11-14 */) && (millis() - t1 < 6000));

  // Log result
  struct tm tinfo;
  localtime_r(&now, &tinfo);
  char buf[64];
  strftime(buf, sizeof(buf), "%c %Z (offset %z)", &tinfo);
  Serial.printf("[NTP] %s  (%ld)\n", buf, (long)now);

  bool ok = now > 1700000000;
  M5.Display.drawString(ok ? "Time OK" : "Time FAIL", M5.Display.width()/2, M5.Display.height()/2 + 46);

  // Optional: power down Wi-Fi after sync
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);

  return ok;
}

// ========================================================
//   CLOCK MODE (Big, centered; time 4×, date 3×)
// ========================================================
void drawClockScreen(){
  uint16_t paper = useGBPalette ? GB_PAPER : COL_BLACK;
  uint16_t ink   = useGBPalette ? GB_DARK  : COL_WHITE;
  uint16_t acc   = useGBPalette ? GB_LITE  : COL_WHITE;

  M5.Display.fillScreen(paper);

  time_t now; struct tm tinfo;
  time(&now);
  localtime_r(&now, &tinfo);

  char hhmm[6];
  if (use24h) strftime(hhmm, sizeof(hhmm), "%H:%M", &tinfo);
  else { strftime(hhmm, sizeof(hhmm), "%I:%M", &tinfo); if (hhmm[0] == '0') hhmm[0] = ' '; }

  char date[40];
  strftime(date, sizeof(date), "%a %b %d %Y", &tinfo);

  int cx = M5.Display.width() / 2;

  // Time: large
  M5.Display.setTextDatum(middle_center);
  M5.Display.setFont(&fonts::Font4);
  M5.Display.setTextSize(4);         // 4× scale
  int yTime = 80;
  M5.Display.setTextColor(ink, paper);
  M5.Display.drawString(hhmm, cx, yTime);

  // Date: big
  M5.Display.setFont(&fonts::Font2);
  M5.Display.setTextSize(3);         // 3× scale
  int yDate = yTime + 70;
  M5.Display.setTextColor(acc, paper);
  M5.Display.drawString(date, cx, yDate);

  // Debug TZ offset (uncomment if needed): shows -0700 / -0800
  // char zbuf[8]; strftime(zbuf, sizeof(zbuf), "%z", &tinfo);
  // M5.Display.setTextSize(1); M5.Display.drawString(zbuf, cx, yDate + 24);
}

// ========================================================
//   BOOT CHIME (tones; no external libs)
// ========================================================
void playBootChime() {
  M5.Speaker.setVolume(200); // 0..255

  struct Note { uint16_t f; uint16_t ms; uint16_t gap; };
  static const Note song[] = {
    {880, 120, 40},   // A5
    {988, 120, 40},   // B5
    {1046,120, 60},   // C6
    {0,   80,  0},    // rest
    {1319,140, 40},   // E6
    {1046,160, 40},   // C6
    {880, 200, 0},    // A5 sustain
  };

  for (auto &n : song) {
    if (n.f) M5.Speaker.tone(n.f, n.ms);
    delay(n.ms + n.gap);
  }
  M5.Speaker.stop();
}

// ========================================================
//   BOOT ANIMATION (25 FPS, JPG frames from /boot/)
//   No clears between frames to prevent flicker
// ========================================================
void playStartupFramesOnce(){
  std::vector<String> frames = listImagesIn(BOOT_DIR);
  if (frames.empty()) return;

  const uint32_t frameDelay = 1000 / BOOT_FPS;

  for (size_t i = 0; i < frames.size(); ++i) {
    drawJpgBuffered(frames[i], 0, 0);
    uint32_t t0 = millis();
    // tap top bar to skip
    while (millis() - t0 < frameDelay) {
      M5.update();
      int tx, ty;
      if (M5.Display.getTouch(&tx, &ty) && ty <= TOUCH_BAR_H) return;
      delay(1);
    }
  }
}

// ========================================================
//   UI HELPERS
// ========================================================
void showToast(const String& msg){
  int w = M5.Display.width(), h = M5.Display.height();
  int bw = w * 2 / 3, bh = 28;
  int x = (w - bw)/2, y = h - bh - 8;

  M5.Display.fillRect(x, y, bw, bh, COL_BLACK);
  M5.Display.drawRect(x, y, bw, bh, useGBPalette ? GB_LITE : COL_WHITE);
  M5.Display.setTextDatum(middle_center);
  M5.Display.setTextColor(useGBPalette ? GB_LITE : COL_WHITE, COL_BLACK);
  M5.Display.drawString(msg, w/2, y + bh/2);
}

// ========================================================
//   SETUP & LOOP
// ========================================================
void setup(){
  Serial.begin(115200);
  delay(50);
  Serial.println("\n[BOOT] Starting Core2 sketch…");

  auto cfg = M5.config();
  M5.begin(cfg);
  M5.Display.setRotation(3);       // landscape with USB on right
  M5.Touch.begin(&M5.Display);

  if (!SD.begin(GPIO_NUM_4, SPI, 25000000)) {
    Serial.println("[SD] init FAILED");
    M5.Display.fillScreen(COL_BLACK);
    M5.Display.setTextDatum(middle_center);
    M5.Display.setTextColor(COL_WHITE, COL_BLACK);
    M5.Display.drawString("SD init failed", M5.Display.width()/2, M5.Display.height()/2);
    return;
  }
  Serial.println("[SD] init OK");

  // // If you need to force-set the clock offline, call once then comment back out:
  // setRTC(2025, 9, 30, 17, 21, 0, TZ_PACIFIC);

  // Wi-Fi & NTP sync with on-screen + serial feedback
  bool ntpOK = syncTimeViaWiFi();
  Serial.printf("[NTP] Sync %s\n", ntpOK ? "OK" : "FAILED");

  // Sound first, then boot frames
  playBootChime();
  playStartupFramesOnce();

  // Load gallery
  files = listImagesIn(MEDIA_DIR);
  Serial.printf("[MEDIA] found %u images\n", (unsigned)files.size());
  showCurrent();
}

void loop(){
  M5.update();

  // ----- Top bar touch handling -----
  int tx, ty; bool touching = M5.Display.getTouch(&tx, &ty);

  if (!barDown && touching && ty <= TOUCH_BAR_H) {
    int third = M5.Display.width() / 3;
    barBtn = (tx < third) ? 1 : (tx < 2*third ? 2 : 3);
    barDown = true; barDownAt = millis();
  }

  if (barDown && (!touching || ty > TOUCH_BAR_H)) {
    uint32_t ms = millis() - barDownAt;

    if (currentMode == MODE_GALLERY) {
      if (barBtn == 1) nextItem(-1);
      else if (barBtn == 2) {
        if (ms >= LONG_PRESS_MS) { currentMode = MODE_CLOCK; drawClockScreen(); }
        else { slideshowOn = !slideshowOn; showToast(String("Slideshow: ") + (slideshowOn ? "ON" : "OFF")); lastSlideMs = millis(); }
      }
      else if (barBtn == 3) nextItem(+1);
    } else { // MODE_CLOCK
      if (barBtn == 1) { use24h = !use24h; drawClockScreen(); }
      else if (barBtn == 2) { currentMode = MODE_GALLERY; showCurrent(); }
      else if (barBtn == 3) { useGBPalette = !useGBPalette; drawClockScreen(); }
    }

    barDown = false;
  }

  // ----- Slideshow -----
  if (currentMode == MODE_GALLERY && slideshowOn && (millis() - lastSlideMs >= SLIDE_INTERVAL_MS)) {
    nextItem(+1);
  }

  // ----- Clock refresh (once/minute) -----
  static uint32_t lastClockMs = 0;
  if (currentMode == MODE_CLOCK && millis() - lastClockMs >= 60000) {
    lastClockMs = millis();
    drawClockScreen();
  }
}