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.
From the outside, it looks like the stock LEGO kit. Inside, it’s been gutted and rebuilt.
This project started with the Arduino IDE and quickly spiraled:
It was messy, but each step made it feel more like a working Game Boy.
It doesn’t play actual games (yet), but the vibe is spot-on.
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.
If you’re starting from a clean install, here’s what I had to do:
Install the Arduino IDE
Add ESP32 board support
https://espressif.github.io/arduino-esp32/package_esp32_index.json
ESP32 by Espressif Systems
(tested with version 3.3.1).Select the right board
ESP32 Arduino → M5Stack-Core2
./dev/cu.usbserial-xxxx
device that appears when you plug in the Core2.Install required libraries
M5Unified
M5GFX
TJpg_Decoder
ESP8266Audio
First upload test
M5Unified → Basics → HelloWorld
.Common gotchas
.DS_Store
files sneak into your SD card → delete them or use a cleanup script before testing media.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
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
#include
#include
#include
#include
#include
#include
#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 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 listImagesIn(const char* dir) {
std::vector 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 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();
}
}