diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml
index 4b4fe0b826..46a044e8dd 100644
--- a/bin/config-dist.yaml
+++ b/bin/config-dist.yaml
@@ -153,6 +153,30 @@ Display:
### You can also specify the spi device for the display to use
# spidev: spidev0.0
+### HUB75 RGB LED matrix panel (Raspberry Pi only, via hzeller/rpi-rgb-led-matrix).
+### Requires the librgbmatrix library to be installed (so `pkg-config rgbmatrix` resolves) and
+### the firmware built with it present. meshtasticd must run as root (or with /dev/gpiomem
+### access) and on-board audio disabled (dtparam=audio=off) so it doesn't fight the PWM timing.
+### The library owns the GPIO pins - they are chosen by HardwareMapping, not set individually.
+# Panel: HUB75
+# HUB75:
+# HardwareMapping: adafruit-hat-pwm # regular | adafruit-hat | adafruit-hat-pwm | regular-pi1 ...
+# Rows: 64 # pixel rows per panel (e.g. 32 or 64)
+# Cols: 64 # pixel columns per panel
+# ChainLength: 1 # panels daisy-chained (width = Cols * ChainLength)
+# Parallel: 1 # parallel chains (height = Rows * Parallel)
+# Brightness: 80 # percent, 1..100
+# PWMBits: 11 # lower = higher refresh, fewer colors
+# GPIOSlowdown: 4 # raise for faster Pis / long cables if the panel ghosts or flickers
+# RGBSequence: RGB # reorder if your panel's colors are swapped (e.g. RBG, BGR)
+# ScanMode: 0 # 0=progressive, 1=interlaced
+# RowAddressType: 0 # 0=default, 1=AB-addressed (some 64x64 panels)
+# Multiplexing: 0 # 0=direct, 1=stripe, 2=checker, ...
+# PanelType: "" # e.g. FM6126A for panels needing a special init
+# PixelMapper: "" # e.g. "U-mapper;Rotate:90"
+# LimitRefreshRateHz: 0 # 0 = no limit
+# DisableHardwarePulsing: false
+
Touchscreen:
### Note, at least for now, the touchscreen must have a CS pin defined, even if you let Linux manage the CS switching.
@@ -167,7 +191,6 @@ Touchscreen:
### You can also specify the spi device for the touchscreen to use
# spidev: spidev0.0
-
Input:
### Configure device for direct keyboard input
diff --git a/bin/config.d/display-hub75-64x64.yaml b/bin/config.d/display-hub75-64x64.yaml
new file mode 100644
index 0000000000..704a4c6b9b
--- /dev/null
+++ b/bin/config.d/display-hub75-64x64.yaml
@@ -0,0 +1,37 @@
+Meta:
+ name: HUB75 RGB LED Matrix (64x64)
+ support: community
+ compatible:
+ - raspberry-pi
+
+### HUB75 RGB LED matrix panel driven by hzeller/rpi-rgb-led-matrix.
+###
+### Prerequisites:
+### - Install the rpi-rgb-led-matrix library so `pkg-config rgbmatrix` resolves, and build the
+### firmware with it present (the HUB75 backend is compiled in automatically when found).
+### - meshtasticd must run as root (or have /dev/gpiomem access) for the library's direct GPIO.
+### - Disable on-board audio (dtparam=audio=off in /boot/firmware/config.txt) - it shares the
+### PWM hardware and causes flicker otherwise.
+###
+### The library owns the GPIO pins; they are selected by HardwareMapping (matching your HAT /
+### wiring), not assigned individually.
+Display:
+ Panel: HUB75
+ HUB75:
+ HardwareMapping: adafruit-hat-pwm
+ Rows: 64
+ Cols: 64
+ ChainLength: 1
+ Parallel: 1
+ Brightness: 80
+ PWMBits: 11
+ GPIOSlowdown: 4
+ RGBSequence: RGB
+ # Uncomment/tune as needed for your specific panel:
+ # ScanMode: 0
+ # RowAddressType: 0
+ # Multiplexing: 0
+ # PanelType: FM6126A
+ # PixelMapper: ""
+ # LimitRefreshRateHz: 0
+ # DisableHardwarePulsing: false
diff --git a/src/configuration.h b/src/configuration.h
index eb3db7e464..672d8b6f19 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -424,6 +424,11 @@ along with this program. If not, see .
#ifndef HAS_TFT
#define HAS_TFT 0
#endif
+// Opt-in: build the BaseUI games frame (Snake). Off by default; enable per build/variant with
+// -DBASEUI_HAS_GAMES=1 (requires HAS_SCREEN and a non-color BaseUI display).
+#ifndef BASEUI_HAS_GAMES
+#define BASEUI_HAS_GAMES 0
+#endif
#ifndef HAS_WIRE
#define HAS_WIRE 0
#endif
diff --git a/src/graphics/HUB75Display.cpp b/src/graphics/HUB75Display.cpp
new file mode 100644
index 0000000000..36ca64f399
--- /dev/null
+++ b/src/graphics/HUB75Display.cpp
@@ -0,0 +1,156 @@
+#include "configuration.h"
+
+#if defined(USE_HUB75)
+
+#include "HUB75Display.h"
+#include "TFTColorRegions.h"
+#include "TFTPalette.h"
+#include
+#include
+
+HUB75Display::HUB75Display(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C)
+{
+ // The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306
+ // page layout). Geometry is fixed by the wired panel size.
+#if defined(SCREEN_ROTATE)
+ setGeometry(GEOMETRY_RAWMODE, TFT_HEIGHT, TFT_WIDTH);
+#else
+ setGeometry(GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
+#endif
+ LOG_DEBUG("HUB75Display %dx%d", (int)TFT_WIDTH, (int)TFT_HEIGHT);
+}
+
+HUB75Display::~HUB75Display()
+{
+ if (matrix) {
+ delete matrix;
+ matrix = nullptr;
+ }
+}
+
+// Bring up the matrix DMA driver from the wired HUB75 pins.
+bool HUB75Display::connect()
+{
+ LOG_INFO("Do HUB75 init");
+
+ HUB75_I2S_CFG::i2s_pins pins = {HUB75_R1, HUB75_G1, HUB75_B1, HUB75_R2, HUB75_G2, HUB75_B2, HUB75_A,
+ HUB75_B, HUB75_C, HUB75_D, HUB75_E, HUB75_LAT, HUB75_OE, HUB75_CLK};
+
+ HUB75_I2S_CFG cfg(TFT_WIDTH, TFT_HEIGHT, 1 /* chain length */, pins);
+
+ cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity)
+ cfg.latch_blanking = 4; // clean row transitions
+ cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half
+ cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA
+ // footprint. display() draws directly into the
+ // live buffer with a dirty-diff
+
+ matrix = new MatrixPanel_I2S_DMA(cfg);
+ bool ok = matrix->begin();
+ if (!ok) {
+ LOG_ERROR("HUB75 matrix->begin() failed (DMA buffer alloc?)");
+ delete matrix;
+ matrix = nullptr;
+ return false;
+ }
+ matrix->setBrightness8(brightness);
+ matrix->clearScreen();
+ return true;
+}
+
+void HUB75Display::display()
+{
+ if (!matrix)
+ return;
+
+ const uint16_t onNative = graphics::TFTPalette::White;
+ const uint16_t offNative = graphics::getThemeBodyBg();
+
+ const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8));
+ const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8));
+
+ bool forceFull = firstFrame || !haveThemeDefaults || onBe != lastOnBe || offBe != lastOffBe;
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
+ const uint32_t colorSig = graphics::getTFTColorFrameSignature();
+ if (colorSig != lastColorSig)
+ forceFull = true;
+#endif
+
+ for (uint16_t y = 0; y < displayHeight; y++) {
+ const uint32_t yByteIndex = (y / 8) * displayWidth;
+ const uint8_t yByteMask = (uint8_t)(1 << (y & 7));
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (hasColorRegions)
+ graphics::beginTFTColorRow((int16_t)y);
+#endif
+
+ for (uint16_t x = 0; x < displayWidth; x++) {
+ const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0;
+
+ // Skip pixels whose mono bit is unchanged (unless a full repaint is
+ // forced). The panel is single-buffered, so untouched pixels persist.
+ if (!forceFull && (((buffer_back[x + yByteIndex] & yByteMask) != 0) == isset))
+ continue;
+
+ uint16_t be;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (hasColorRegions)
+ be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe);
+ else
+ be = isset ? onBe : offBe;
+#else
+ be = isset ? onBe : offBe;
+#endif
+
+ const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565
+ const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3);
+ const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2);
+ const uint8_t b = (uint8_t)((c & 0x1F) << 3);
+ matrix->drawPixelRGB888(x, y, r, g, b);
+ }
+ }
+
+ // Remember what the panel now shows so the next frame can diff against it.
+ memcpy(buffer_back, buffer, displayBufferSize);
+
+ haveThemeDefaults = true;
+ lastOnBe = onBe;
+ lastOffBe = offBe;
+ firstFrame = false;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ lastColorSig = colorSig;
+ // Regions are re-registered every frame by the renderers; clear so they
+ // don't accumulate across frames.
+ graphics::clearTFTColorRegions();
+#endif
+}
+
+void HUB75Display::sendCommand(uint8_t com)
+{
+ if (!matrix)
+ return;
+
+ switch (com) {
+ case DISPLAYON:
+ matrix->setBrightness8(brightness);
+ break;
+ case DISPLAYOFF:
+ matrix->setBrightness8(0);
+ break;
+ default:
+ // Drop all other SSD1306 init/config commands - not meaningful for the matrix.
+ break;
+ }
+}
+
+void HUB75Display::setDisplayBrightness(uint8_t _brightness)
+{
+ brightness = _brightness;
+ if (matrix)
+ matrix->setBrightness8(brightness);
+}
+
+#endif // USE_HUB75
diff --git a/src/graphics/HUB75Display.h b/src/graphics/HUB75Display.h
new file mode 100644
index 0000000000..aa17a5a2a6
--- /dev/null
+++ b/src/graphics/HUB75Display.h
@@ -0,0 +1,48 @@
+#pragma once
+
+#include "configuration.h"
+
+#if defined(USE_HUB75)
+
+#include
+
+#ifndef HUB75_BRIGHTNESS_DEFAULT
+#define HUB75_BRIGHTNESS_DEFAULT 180
+#endif
+
+class MatrixPanel_I2S_DMA;
+
+/**
+ * A color display backend that drives a HUB75 RGB LED matrix panel from the
+ * BaseUI color path.
+ */
+class HUB75Display : public OLEDDisplay
+{
+ public:
+ HUB75Display(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
+ ~HUB75Display();
+
+ // Write the buffer to the matrix (full-frame, region-aware color expansion).
+ void display() override;
+
+ void setDisplayBrightness(uint8_t brightness);
+
+ protected:
+ int getBufferOffset(void) override { return 0; }
+
+ void sendCommand(uint8_t com) override;
+
+ bool connect() override;
+
+ private:
+ MatrixPanel_I2S_DMA *matrix = nullptr;
+ uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT;
+
+ bool firstFrame = true;
+ bool haveThemeDefaults = false;
+ uint16_t lastOnBe = 0;
+ uint16_t lastOffBe = 0;
+ uint32_t lastColorSig = 0;
+};
+
+#endif // USE_HUB75
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 024fa409f2..73bf04b6db 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -29,6 +29,12 @@ along with this program. If not, see .
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include
+#if defined(USE_HUB75)
+#include "graphics/HUB75Display.h" // ESP32 HUB75 (I2S-DMA)
+#endif
+#if defined(HAS_HUB75_NATIVE)
+#include "HUB75Native.h" // native/Portduino HUB75 (rpi-rgb-led-matrix), from variants/native/portduino
+#endif
#include "DisplayFormatters.h"
#include "TimeFormatters.h"
@@ -67,6 +73,9 @@ along with this program. If not, see .
#include "mesh/Default.h"
#include "mesh/generated/meshtastic/deviceonly.pb.h"
#include "modules/ExternalNotificationModule.h"
+#if BASEUI_HAS_GAMES
+#include "modules/games/GamesModule.h"
+#endif
#include "modules/WaypointModule.h"
#include "sleep.h"
#include "target_specific.h"
@@ -99,7 +108,11 @@ namespace graphics
#define COMPASS_ACTIVE_FRAMERATE 20
// DEBUG
+#if BASEUI_HAS_GAMES
+#define NUM_EXTRA_FRAMES 4 // text message, debug frame, and the always-present games frame
+#else
#define NUM_EXTRA_FRAMES 3 // text message and debug frame
+#endif
// if defined a pixel will blink to show redraws
// #define SHOW_REDRAWS
#define ASCII_BELL '\x07'
@@ -369,6 +382,43 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
updateUiFrame(ui);
}
+// Called to trigger an arcade-style initials picker (see showNumberPicker for the sibling flow).
+void Screen::showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length,
+ std::function bannerCallback)
+{
+#ifdef USE_EINK
+ EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus
+#endif
+ if (length >= sizeof(NotificationRenderer::alphanumericValue))
+ length = sizeof(NotificationRenderer::alphanumericValue) - 1;
+
+ strncpy(NotificationRenderer::alertBannerMessage, message, 255);
+ NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination
+ NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs;
+ NotificationRenderer::textInputCallback = bannerCallback;
+ NotificationRenderer::pauseBanner = false;
+ NotificationRenderer::curSelected = 0;
+ NotificationRenderer::current_notification_type = notificationTypeEnum::alphanumeric_picker;
+ NotificationRenderer::numDigits = length;
+
+ // Seed each position from initialText (uppercased & filtered to A-Z/0-9), defaulting to 'A'.
+ const size_t seedLen = initialText ? strnlen(initialText, length) : 0;
+ for (uint8_t i = 0; i < length; i++) {
+ char c = (i < seedLen) ? initialText[i] : 'A';
+ if (c >= 'a' && c <= 'z')
+ c = static_cast(c - 'a' + 'A');
+ if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
+ c = 'A';
+ NotificationRenderer::alphanumericValue[i] = c;
+ }
+ NotificationRenderer::alphanumericValue[length] = '\0';
+
+ static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
+ ui->setOverlays(overlays, 2);
+ ui->setTargetFPS(60);
+ updateUiFrame(ui);
+}
+
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function textCallback)
{
@@ -412,6 +462,17 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
pi.drawFrame(display, state, x, y);
}
+#if BASEUI_HAS_GAMES
+// The games frame is a dedicated, always-present frame (unlike generic module frames it is placed
+// at a fixed position right after home), so it draws through its own trampoline rather than the
+// moduleFrames lockstep used by drawModuleFrame.
+static void drawGamesFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ if (gamesModule)
+ gamesModule->drawFrame(display, state, x, y);
+}
+#endif
+
/**
* Given a recent lat/lon return a guess of the heading the user is walking on.
*
@@ -499,6 +560,8 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
#else
dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT);
#endif
+#elif defined(USE_HUB75)
+ dispdev = new HUB75Display(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE);
#elif defined(USE_SSD1306)
dispdev = new SSD1306Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -517,7 +580,17 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
LOG_INFO("SSD1306 init success");
}
#elif ARCH_PORTDUINO
- if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
+
+ // HUB75 RGB matrix (hzeller/rpi-rgb-led-matrix) is a BaseUI framebuffer panel, selected at
+ // runtime via config.yaml Display: Panel: HUB75.
+ if (portduino_config.displayPanel == hub75) {
+#if defined(HAS_HUB75_NATIVE)
+ LOG_DEBUG("Make HUB75Native!");
+ dispdev = new HUB75Native(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE);
+#else
+ LOG_ERROR("HUB75 panel requested but rpi-rgb-led-matrix not compiled in!");
+#endif
+ } else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
if (portduino_config.displayPanel != no_screen) {
LOG_DEBUG("Make TFTDisplay!");
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
@@ -1304,6 +1377,15 @@ void Screen::setFrames(FrameFocus focus)
indicatorIcons.push_back(icon_home);
}
+#if BASEUI_HAS_GAMES
+ // Games frame: always present (even with no game running), positioned directly after home.
+ if (gamesModule) {
+ fsi.positions.games = numframes;
+ normalFrames[numframes++] = drawGamesFrame;
+ indicatorIcons.push_back(joystick_small);
+ }
+#endif
+
fsi.positions.textMessage = numframes;
normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
indicatorIcons.push_back(icon_mail);
@@ -2076,6 +2158,12 @@ int Screen::handleInputEvent(const InputEvent *event)
if (module && module->interceptingKeyboardInput())
inputIntercepted = true;
}
+#if BASEUI_HAS_GAMES
+ // The games frame isn't a moduleFrame, so check it explicitly: while a game is running it
+ // owns the D-pad (turns/pause) and we must not switch frames or open menus underneath it.
+ if (gamesModule && gamesModule->interceptingKeyboardInput())
+ inputIntercepted = true;
+#endif
// If no modules are using the input, move between frames
if (!inputIntercepted) {
@@ -2150,6 +2238,11 @@ int Screen::handleInputEvent(const InputEvent *event)
} else if (event->inputEvent == INPUT_BROKER_SELECT) {
if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) {
menuHandler::homeBaseMenu();
+#if BASEUI_HAS_GAMES
+ } else if (gamesModule && framesetInfo.positions.games != 255 &&
+ this->ui->getUiState()->currentFrame == framesetInfo.positions.games) {
+ gamesModule->launchGame(); // launch the game shown on the attract screen
+#endif
} else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) {
menuHandler::systemBaseMenu();
#if HAS_GPS
@@ -2217,6 +2310,11 @@ bool Screen::isOverlayBannerShowing()
return NotificationRenderer::isOverlayBannerShowing();
}
+bool Screen::isGamesFrameShown()
+{
+ return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games;
+}
+
} // namespace graphics
#else
diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h
index d728269969..4aeec6ce8d 100644
--- a/src/graphics/Screen.h
+++ b/src/graphics/Screen.h
@@ -26,6 +26,9 @@ enum notificationTypeEnum {
// LOCKED frame. Without this, a first-pair on a locked device cannot
// complete because the PIN never renders.
pairing_pin,
+ // Arcade-style initials entry: like number_picker/hex_picker, but each position cycles
+ // through A-Z and 0-9. The assembled string is returned via a text (std::string) callback.
+ alphanumeric_picker,
};
struct BannerOverlayOptions {
@@ -284,6 +287,10 @@ class Screen : public concurrency::OSThread
bool isOverlayBannerShowing();
+ // True if the always-present games frame is the one currently on screen. Lets the games module
+ // ignore D-pad input when the player has navigated to a different frame.
+ bool isGamesFrameShown();
+
bool isScreenOn() { return screenOn; }
// Stores the last 4 of our hardware ID, to make finding the device for pairing easier
@@ -346,6 +353,11 @@ class Screen : public concurrency::OSThread
void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback);
void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16,
std::function bannerCallback);
+ // Arcade-style initials entry. `length` positions each cycle A-Z/0-9 (UP/DOWN), LEFT/RIGHT
+ // moves the cursor, SELECT advances; the assembled string is delivered to `bannerCallback`.
+ // `initialText` pre-seeds the positions (uppercased & filtered), defaulting to 'A'.
+ void showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length,
+ std::function bannerCallback);
void showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function textCallback);
@@ -735,6 +747,7 @@ class Screen : public concurrency::OSThread
uint8_t system = 255;
uint8_t gps = 255;
uint8_t home = 255;
+ uint8_t games = 255;
uint8_t textMessage = 255;
uint8_t nodelist_nodes = 255;
uint8_t nodelist_location = 255;
diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h
index d0517ad4ee..9de74b85a4 100644
--- a/src/graphics/TFTColorRegions.h
+++ b/src/graphics/TFTColorRegions.h
@@ -16,8 +16,9 @@ struct TFTColorRegion {
// Required by ST7789 driver: it scans until the first disabled entry.
bool enabled = false;
};
-
+#ifndef MAX_TFT_COLOR_REGIONS
static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
+#endif
extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
enum class TFTColorRole : uint8_t {
@@ -39,7 +40,7 @@ enum class TFTColorRole : uint8_t {
Count
};
-#if HAS_TFT || defined(HAS_SPI_TFT)
+#if HAS_TFT || defined(HAS_SPI_TFT) || defined(HAS_HUB75_NATIVE)
#define GRAPHICS_TFT_COLORING_ENABLED 1
#else
#define GRAPHICS_TFT_COLORING_ENABLED 0
diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp
index 243a62932e..b4dd5ab8af 100644
--- a/src/graphics/draw/DebugRenderer.cpp
+++ b/src/graphics/draw/DebugRenderer.cpp
@@ -743,17 +743,18 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
int line = 1;
+ int scale = 1;
int iconX = SCREEN_WIDTH - chirpy_width - (chirpy_width / 3);
int iconY = (SCREEN_HEIGHT - chirpy_height) / 2;
int textX_offset = 10;
if (currentResolution == ScreenResolution::High) {
textX_offset = textX_offset * 4;
- const int scale = 2;
+ scale = 2;
+ iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3);
+ iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2;
const int bytesPerRow = (chirpy_width + 7) / 8;
for (int yy = 0; yy < chirpy_height; ++yy) {
- iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3);
- iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2;
const uint8_t *rowPtr = chirpy + yy * bytesPerRow;
for (int xx = 0; xx < chirpy_width; ++xx) {
const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3));
@@ -767,6 +768,19 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1
display->drawXbm(iconX, iconY, chirpy_width, chirpy_height, chirpy);
}
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Colour Chirpy on colour displays. The glyph is a filled head silhouette whose eyes are holes
+ // (off pixels), so two stacked regions render the proper mascot without a second bitmap:
+ // A) whole glyph -> green body / frame / legs
+ // B) the eye band -> black face, with the eye holes turning white via the region's off-colour
+ // Start the face band one column in from the head's left edge (col 6) so that edge stays green,
+ // matching the green column already left on the right edge (col 31).
+ graphics::registerTFTColorRegionDirect(iconX, iconY, chirpy_width * scale, chirpy_height * scale,
+ graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg());
+ graphics::registerTFTColorRegionDirect(iconX + 7 * scale, iconY + 12 * scale, 24 * scale, 16 * scale,
+ graphics::TFTPalette::Black, graphics::TFTPalette::White);
+#endif
+
int textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("Hello") / 2);
display->drawString(textX, getTextPositions(display)[line++], "Hello");
textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("World!") / 2);
diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp
index 03e53c3bd7..98f229b838 100644
--- a/src/graphics/draw/NotificationRenderer.cpp
+++ b/src/graphics/draw/NotificationRenderer.cpp
@@ -54,6 +54,7 @@ bool NotificationRenderer::pauseBanner = false;
notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none;
uint32_t NotificationRenderer::numDigits = 0;
uint32_t NotificationRenderer::currentNumber = 0;
+char NotificationRenderer::alphanumericValue[16] = {0};
VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr;
std::function NotificationRenderer::textInputCallback = nullptr;
@@ -277,6 +278,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU
case notificationTypeEnum::hex_picker:
drawHexPicker(display, state);
break;
+ case notificationTypeEnum::alphanumeric_picker:
+ drawAlphanumericPicker(display, state);
+ break;
}
}
@@ -462,6 +466,96 @@ void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiStat
drawNotificationBox(display, state, linePointers, totalLines, 0);
}
+// Arcade-style initials entry. Mirrors drawHexPicker's cursor/confirm flow, but each position
+// holds a character from ALPHANUMERIC_CHARS (cycled with UP/DOWN) instead of a packed digit, and
+// the assembled string is returned through textInputCallback.
+void NotificationRenderer::drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state)
+{
+ static const char ALPHANUMERIC_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ constexpr int ALPHANUMERIC_COUNT = sizeof(ALPHANUMERIC_CHARS) - 1; // exclude the NUL
+
+ const char *lineStarts[MAX_LINES + 1] = {0};
+ uint16_t lineCount = 0;
+
+ // Parse lines (identical to the number/hex pickers)
+ char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage));
+ lineStarts[lineCount] = alertBannerMessage;
+ while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) {
+ lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n');
+ if (lineStarts[lineCount + 1][0] == '\n')
+ lineStarts[lineCount + 1] += 1;
+ lineCount++;
+ }
+
+ auto alphaIndex = [&](char c) -> int {
+ for (int i = 0; i < ALPHANUMERIC_COUNT; i++)
+ if (ALPHANUMERIC_CHARS[i] == c)
+ return i;
+ return 0;
+ };
+
+ // Handle input
+ if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS ||
+ inEvent.inputEvent == INPUT_BROKER_UP_LONG) {
+ int idx = (alphaIndex(alphanumericValue[curSelected]) + 1) % ALPHANUMERIC_COUNT;
+ alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx];
+ } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS ||
+ inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) {
+ int idx = (alphaIndex(alphanumericValue[curSelected]) + ALPHANUMERIC_COUNT - 1) % ALPHANUMERIC_COUNT;
+ alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx];
+ } else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) {
+ char k = inEvent.kbchar;
+ if (k >= 'a' && k <= 'z')
+ k = static_cast(k - 'a' + 'A');
+ if ((k >= 'A' && k <= 'Z') || (k >= '0' && k <= '9')) { // direct keyboard entry
+ alphanumericValue[curSelected] = k;
+ curSelected++;
+ }
+ } else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) {
+ curSelected++;
+ } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) {
+ curSelected--;
+ } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) &&
+ alertBannerUntil != 0) {
+ resetBanner();
+ return;
+ }
+
+ if (curSelected < 0)
+ curSelected = 0;
+ if (curSelected == static_cast(numDigits)) {
+ auto callback = textInputCallback; // capture before clearing to avoid re-entrancy surprises
+ std::string result(alphanumericValue, numDigits);
+ textInputCallback = nullptr;
+ resetBanner();
+ if (callback)
+ callback(result);
+ return;
+ }
+
+ inEvent.inputEvent = INPUT_BROKER_NONE;
+ if (alertBannerMessage[0] == '\0')
+ return;
+
+ uint16_t totalLines = lineCount + 2;
+ const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation
+
+ for (uint16_t i = 0; i < lineCount; i++) {
+ linePointers[i] = lineStarts[i];
+ }
+ std::string chars = " ";
+ std::string arrowPointer = " ";
+ for (uint16_t i = 0; i < numDigits; i++) {
+ chars += std::string(1, alphanumericValue[i]) + " ";
+ arrowPointer += (curSelected == static_cast(i)) ? "^ " : "_ ";
+ }
+
+ linePointers[lineCount++] = chars.c_str();
+ linePointers[lineCount++] = arrowPointer.c_str();
+
+ drawNotificationBox(display, state, linePointers, totalLines, 0);
+}
+
void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state)
{
static uint32_t selectedNodenum = 0;
diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h
index 629cf61d80..08f6f74b05 100644
--- a/src/graphics/draw/NotificationRenderer.h
+++ b/src/graphics/draw/NotificationRenderer.h
@@ -26,6 +26,7 @@ class NotificationRenderer
static std::function alertBannerCallback;
static uint32_t numDigits;
static uint32_t currentNumber;
+ static char alphanumericValue[16]; // working buffer for the alphanumeric_picker
static VirtualKeyboard *virtualKeyboard;
static std::function textInputCallback;
@@ -43,6 +44,7 @@ class NotificationRenderer
static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
+ static void drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1],
diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp
index 2e2a35f189..f38d27d7d7 100644
--- a/src/graphics/draw/UIRenderer.cpp
+++ b/src/graphics/draw/UIRenderer.cpp
@@ -9,6 +9,9 @@
#if !MESHTASTIC_EXCLUDE_STATUS
#include "modules/StatusMessageModule.h"
#endif
+#if BASEUI_HAS_GAMES
+#include "modules/games/GamesModule.h"
+#endif
#include "UIRenderer.h"
#include "airtime.h"
#include "gps/GeoCoord.h"
@@ -1817,6 +1820,13 @@ constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
{
+#if BASEUI_HAS_GAMES
+ // Hide the navigation bar while a game owns the screen (the attract screen doesn't intercept,
+ // so the nav bar stays visible there).
+ if (gamesModule && gamesModule->interceptingKeyboardInput())
+ return;
+#endif
+
uint8_t frameToHighlight = state->currentFrame;
if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) {
frameToHighlight = state->transitionFrameTarget;
diff --git a/src/graphics/images.h b/src/graphics/images.h
index 95c0861fd2..86d9efb7ba 100644
--- a/src/graphics/images.h
+++ b/src/graphics/images.h
@@ -319,6 +319,46 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f};
#define connection_icon_height 5
const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36};
+// Joystick icon (16x16): a round knob on a shaft over an elliptical base. Drawn on the Snake
+// attract screen as a "use the D-pad" controls hint.
+#define joystick_width 16
+#define joystick_height 16
+static const uint8_t joystick[] PROGMEM = {0xC0, 0x03, 0xE0, 0x07, 0xF0, 0x0F, 0xF0, 0x0F, 0xE0, 0x07, 0xC0,
+ 0x03, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0xF8, 0x1F,
+ 0xFC, 0x3F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFC, 0x3F, 0xF8, 0x1F};
+
+// 8x8 joystick, for the navigation bar (which draws 8x8 glyphs, scaled up on hi-res displays).
+#define joystick_small_width 8
+#define joystick_small_height 8
+static const uint8_t joystick_small[] PROGMEM = {0x18, 0x3C, 0x3C, 0x18, 0x7E, 0x99, 0xC3, 0x7E};
+
+#define snake_width 16
+#define snake_height 16
+static const uint8_t snake[] PROGMEM = {0x80, 0x1F, 0x40, 0x20, 0x20, 0x48, 0x20, 0x40, 0xA0, 0x44, 0x20,
+ 0x39, 0x40, 0xDE, 0x86, 0x44, 0x0A, 0x19, 0x32, 0x22, 0xC4, 0x47,
+ 0x1C, 0x4D, 0x6A, 0x4A, 0xFA, 0x47, 0x04, 0x20, 0xF8, 0x1F};
+
+#define tetris_width 16
+#define tetris_height 16
+static const uint8_t tetris[] PROGMEM = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x01, 0x80, 0xFD, 0xBF, 0xFD,
+ 0xBF, 0x81, 0x81, 0xBF, 0xFD, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05,
+ 0x20, 0x04, 0xE0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
+
+#define breakout_width 16
+#define breakout_height 16
+// Three staggered brick courses, a ball, and a paddle. XBM: 2 bytes/row, LSB = leftmost pixel.
+static const uint8_t breakout[] PROGMEM = {0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0xDD, 0xDD, 0xDD,
+ 0xDD, 0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0x80, 0x01,
+ 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0xF0, 0x0F};
+
+// Tiny Chirpy runner sprite: square head with two pill eyes + mouth, antenna, and long legs.
+// XBM, 2 bytes/row, LSB = leftmost pixel. Used by the Chirpy Runner game.
+#define chirpy_run_width 12
+#define chirpy_run_height 16
+static const uint8_t chirpy_run[] PROGMEM = {0x40, 0x00, 0x20, 0x00, 0x40, 0x00, 0xfc, 0x03, 0x04, 0x02, 0x94,
+ 0x02, 0x94, 0x02, 0x94, 0x02, 0x04, 0x02, 0xf4, 0x02, 0xfc, 0x03,
+ 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x08, 0x01};
+
#ifdef OLED_TINY
#include "img/icon_small.xbm"
#else
diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp
index aaff439592..f6848a5908 100644
--- a/src/input/LinuxJoystick.cpp
+++ b/src/input/LinuxJoystick.cpp
@@ -79,6 +79,16 @@ static int axisZone(int value)
return 0;
}
+void LinuxJoystick::emitEvent(input_broker_event event)
+{
+ InputEvent e = {};
+ e.inputEvent = event;
+ e.source = this->_originName;
+ e.kbchar = 0;
+ // LOG_DEBUG("joystick: %s event %d", this->_originName, event);
+ this->notifyObservers(&e);
+}
+
int32_t LinuxJoystick::runOnce()
{
if (firstTime) {
@@ -107,8 +117,6 @@ int32_t LinuxJoystick::runOnce()
if (nfds < 0) {
perror("joystick: epoll_wait failed");
return disable();
- } else if (nfds == 0) {
- return 50;
}
for (int i = 0; i < nfds; i++) {
@@ -117,42 +125,53 @@ int32_t LinuxJoystick::runOnce()
if (rd < (signed int)sizeof(struct input_event))
continue;
for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) {
- InputEvent e = {};
- e.inputEvent = INPUT_BROKER_NONE;
- e.source = this->_originName;
- e.kbchar = 0;
unsigned int type = evs[j].type;
unsigned int code = evs[j].code;
int value = evs[j].value;
if (type == EV_ABS) {
- // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values.
- // Emit exactly once when an axis crosses from center to an edge.
+ // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. Emit on the
+ // transition to an edge and arm auto-repeat; a held direction repeats below.
if (code == ABS_X) {
int zone = axisZone(value);
- if (zone != axisZone(lastX) && zone != 0)
- e.inputEvent = (zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT;
- lastX = value;
+ if (zone != heldX) {
+ heldX = zone;
+ if (zone != 0) {
+ emitEvent((zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT);
+ nextRepeatX = millis() + JOY_REPEAT_DELAY_MS;
+ }
+ }
} else if (code == ABS_Y) {
int zone = axisZone(value);
- if (zone != axisZone(lastY) && zone != 0)
- e.inputEvent = (zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN;
- lastY = value;
+ if (zone != heldY) {
+ heldY = zone;
+ if (zone != 0) {
+ emitEvent((zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN);
+ nextRepeatY = millis() + JOY_REPEAT_DELAY_MS;
+ }
+ }
}
} else if (type == EV_KEY && value == 1) {
// Look up the configured action for this button; unmapped buttons are ignored.
+ // Buttons fire once per press (no auto-repeat).
auto mapped = buttonMap.find(code);
if (mapped != buttonMap.end())
- e.inputEvent = mapped->second;
- }
-
- if (e.inputEvent != INPUT_BROKER_NONE) {
- LOG_DEBUG("joystick: %s event %d", this->_originName, e.inputEvent);
- this->notifyObservers(&e);
+ emitEvent(mapped->second);
}
}
}
+ // Auto-repeat held D-pad directions. Signed comparison is wraparound-safe.
+ uint32_t now = millis();
+ if (heldX != 0 && (int32_t)(now - nextRepeatX) >= 0) {
+ emitEvent((heldX < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT);
+ nextRepeatX = now + JOY_REPEAT_INTERVAL_MS;
+ }
+ if (heldY != 0 && (int32_t)(now - nextRepeatY) >= 0) {
+ emitEvent((heldY < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN);
+ nextRepeatY = now + JOY_REPEAT_INTERVAL_MS;
+ }
+
return 50; // Poll every 50msec
}
diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h
index 6e6d3c398f..7e309b9616 100644
--- a/src/input/LinuxJoystick.h
+++ b/src/input/LinuxJoystick.h
@@ -20,6 +20,10 @@
#define JOY_AXIS_LOW 64 // below this -> "min" edge (0)
#define JOY_AXIS_HIGH 192 // above this -> "max" edge (255)
+// D-pad auto-repeat while a direction is held (typematic).
+#define JOY_REPEAT_DELAY_MS 400 // hold this long before repeats start
+#define JOY_REPEAT_INTERVAL_MS 150 // then repeat this often
+
class LinuxJoystick : public Observable, public concurrency::OSThread
{
public:
@@ -27,10 +31,18 @@ class LinuxJoystick : public Observable, public concurrency:
void init(); // Registers this source with the InputBroker
void deInit(); // Strictly for cleanly "rebooting" the binary on native
+ // Current held D-pad zone, updated the instant the axis moves (independent of the typematic
+ // auto-repeat). -1 = left/up edge, 0 = centered, +1 = right/down edge. Lets a game poll for
+ // smooth continuous control instead of waiting for the slow repeat events.
+ int heldXZone() const { return heldX; }
+ int heldYZone() const { return heldY; }
+
protected:
virtual int32_t runOnce() override;
private:
+ void emitEvent(input_broker_event event);
+
const char *_originName;
bool firstTime = true;
@@ -42,10 +54,12 @@ class LinuxJoystick : public Observable, public concurrency:
int fd = -1;
int epollfd = -1;
- // Latch the last emitted axis position so a held direction fires exactly
- // once (on the transition away from center), not a continuous stream.
- int lastX = JOY_AXIS_CENTER;
- int lastY = JOY_AXIS_CENTER;
+ // D-pad auto-repeat state: currently held zone per axis (-1 / 0 / +1) and the
+ // next millis() timestamp at which to re-emit while the direction is held.
+ int heldX = 0;
+ int heldY = 0;
+ uint32_t nextRepeatX = 0;
+ uint32_t nextRepeatY = 0;
};
extern LinuxJoystick *aLinuxJoystick;
#endif
diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp
index 1e93805750..546651c5bc 100644
--- a/src/modules/Modules.cpp
+++ b/src/modules/Modules.cpp
@@ -19,6 +19,9 @@
#if !MESHTASTIC_EXCLUDE_CANNEDMESSAGES
#include "modules/CannedMessageModule.h"
#endif
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+#include "modules/games/GamesModule.h"
+#endif
#if !MESHTASTIC_EXCLUDE_DETECTIONSENSOR
#include "modules/DetectionSensorModule.h"
#endif
@@ -206,6 +209,11 @@ void setupModules()
cannedMessageModule = new CannedMessageModule();
}
#endif
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
+ gamesModule = new GamesModule();
+ }
+#endif
#if ARCH_PORTDUINO
new HostMetricsModule();
#endif
diff --git a/src/modules/games/Breakout.cpp b/src/modules/games/Breakout.cpp
new file mode 100644
index 0000000000..f60f4d8bf3
--- /dev/null
+++ b/src/modules/games/Breakout.cpp
@@ -0,0 +1,315 @@
+#include "Breakout.h"
+
+// ===========================================================================
+// Pure BreakoutGame logic (no display/FS dependencies; always compiled)
+// ===========================================================================
+
+uint32_t BreakoutGame::nextRandom()
+{
+ uint32_t x = rng;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ rng = x;
+ return x;
+}
+
+void BreakoutGame::buildBricks()
+{
+ for (uint8_t r = 0; r < BRICK_ROWS; r++)
+ for (uint8_t c = 0; c < BRICK_COLS; c++)
+ bricks[r][c] = 1;
+ bricksLeft = static_cast(BRICK_ROWS) * BRICK_COLS;
+}
+
+void BreakoutGame::serveBall()
+{
+ // Centre the paddle and launch the ball upward from just above it, at a slight angle whose
+ // side is chosen randomly so successive serves are not identical.
+ paddleLeft = (BOARD_W - PADDLE_W) / 2;
+ ballPxX = static_cast(BOARD_W / 2) * SUBPX;
+ ballPxY = static_cast(PADDLE_Y - 2) * SUBPX;
+ ballVx = (nextRandom() & 1u) ? 28 : -28;
+ ballVy = -BALL_VY;
+}
+
+void BreakoutGame::nextLevel()
+{
+ levelNum++;
+ buildBricks();
+ serveBall();
+}
+
+void BreakoutGame::reset(uint32_t seed)
+{
+ rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
+ points = 0;
+ livesLeft = START_LIVES;
+ levelNum = 1;
+ alive = true;
+ ballTick = false;
+ buildBricks();
+ serveBall();
+}
+
+void BreakoutGame::movePaddle(int16_t dxPx)
+{
+ if (!alive)
+ return;
+ paddleLeft += dxPx;
+ if (paddleLeft < 0)
+ paddleLeft = 0;
+ else if (paddleLeft > BOARD_W - PADDLE_W)
+ paddleLeft = BOARD_W - PADDLE_W;
+}
+
+void BreakoutGame::moveLeft()
+{
+ movePaddle(-PADDLE_STEP);
+}
+
+void BreakoutGame::moveRight()
+{
+ movePaddle(PADDLE_STEP);
+}
+
+bool BreakoutGame::step()
+{
+ if (!alive)
+ return false;
+
+ // The ball advances on every other step() so the caller can tick (and poll the paddle) at twice
+ // the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster.
+ ballTick = !ballTick;
+ if (!ballTick)
+ return true;
+
+ ballPxX += ballVx;
+ ballPxY += ballVy;
+
+ int16_t px = static_cast(ballPxX / SUBPX);
+ int16_t py = static_cast(ballPxY / SUBPX);
+
+ // Side walls.
+ if (px <= 0) {
+ ballPxX = 0;
+ px = 0;
+ ballVx = -ballVx;
+ } else if (px >= BOARD_W - 1) {
+ ballPxX = static_cast(BOARD_W - 1) * SUBPX;
+ px = BOARD_W - 1;
+ ballVx = -ballVx;
+ }
+ // Top wall.
+ if (py <= 0) {
+ ballPxY = 0;
+ py = 0;
+ ballVy = -ballVy;
+ }
+
+ // Bricks: at most one brick per step (single, blocky reflection off the bottom/top face).
+ if (py >= BRICK_TOP && py < BRICK_TOP + BRICK_ROWS * BRICK_H) {
+ const int r = (py - BRICK_TOP) / BRICK_H;
+ const int c = px / BRICK_W;
+ if (r >= 0 && r < BRICK_ROWS && c >= 0 && c < BRICK_COLS && bricks[r][c]) {
+ bricks[r][c] = 0;
+ bricksLeft--;
+ points += POINTS_PER_BRICK;
+ ballVy = -ballVy;
+ if (bricksLeft == 0) {
+ nextLevel();
+ return true;
+ }
+ }
+ }
+
+ // Paddle: bounce up and steer horizontally based on where the ball struck.
+ if (ballVy > 0 && py >= PADDLE_Y - 1 && py <= PADDLE_Y + PADDLE_H) {
+ if (px >= paddleLeft && px < paddleLeft + PADDLE_W) {
+ ballPxY = static_cast(PADDLE_Y - 1) * SUBPX;
+ ballVy = -BALL_VY;
+ // Six zones across the paddle map to increasing outward angles; no zone is vertical.
+ static const int16_t vxByZone[6] = {-48, -28, -8, 8, 28, 48};
+ int zone = ((px - paddleLeft) * 6) / PADDLE_W;
+ if (zone < 0)
+ zone = 0;
+ else if (zone > 5)
+ zone = 5;
+ ballVx = vxByZone[zone];
+ }
+ }
+
+ // Ball lost past the bottom edge.
+ if (py >= BOARD_H) {
+ if (livesLeft > 0)
+ livesLeft--;
+ if (livesLeft == 0) {
+ alive = false;
+ return false;
+ }
+ serveBall();
+ }
+
+ return alive;
+}
+
+// ===========================================================================
+// Breakout adapter (display + persistence; BaseUI games build only)
+// ===========================================================================
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "graphics/Screen.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
+#include "graphics/images.h"
+#include "main.h"
+#if ARCH_PORTDUINO && defined(__linux__)
+#include "input/LinuxJoystick.h"
+#endif
+
+// Paddle pixels moved per tick while a joystick direction is held (continuous polling path).
+static constexpr int16_t PADDLE_POLL_STEP = 3;
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+// Classic Breakout brick-wall colours, top row to bottom.
+static uint16_t brickRowColor(uint8_t row)
+{
+ using namespace graphics;
+ switch (row) {
+ case 0:
+ return TFTPalette::Red;
+ case 1:
+ return TFTPalette::Orange;
+ case 2:
+ return TFTPalette::Yellow;
+ case 3:
+ return TFTPalette::Green;
+ default:
+ return TFTPalette::Cyan;
+ }
+}
+#endif
+
+Breakout::Breakout()
+{
+ scores_.load();
+}
+
+int32_t Breakout::tickIntervalMs() const
+{
+ // Tick at twice the ball's cadence: the ball advances every other step() (BreakoutGame::step),
+ // so halving the interval keeps the ball speed the same while the paddle is polled/redrawn twice
+ // as often. Speed ramps with level: ~22 ms base, floor 10 ms.
+ int32_t iv = 45 - static_cast(game.level() - 1) * 5;
+ if (iv < 20)
+ iv = 20;
+ return iv / 2;
+}
+
+bool Breakout::tick()
+{
+#if ARCH_PORTDUINO && defined(__linux__)
+ // Poll the joystick's held direction directly so the paddle glides continuously instead of
+ // creeping along at the D-pad's slow auto-repeat rate.
+ if (aLinuxJoystick) {
+ const int held = aLinuxJoystick->heldXZone();
+ if (held < 0)
+ game.movePaddle(-PADDLE_POLL_STEP);
+ else if (held > 0)
+ game.movePaddle(PADDLE_POLL_STEP);
+ }
+#endif
+ return game.step();
+}
+
+void Breakout::handleInput(input_broker_event ev)
+{
+#if ARCH_PORTDUINO && defined(__linux__)
+ // When a joystick is present the paddle is polled continuously in tick(); ignore the discrete
+ // (and slow) repeat events so we don't double-move.
+ if (aLinuxJoystick)
+ return;
+#endif
+ switch (ev) {
+ case INPUT_BROKER_LEFT:
+ game.moveLeft();
+ break;
+ case INPUT_BROKER_RIGHT:
+ game.moveRight();
+ break;
+ default:
+ break;
+ }
+}
+
+void Breakout::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+ const int16_t w = display->getWidth();
+ const int16_t cx = x + w / 2;
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(cx, y, "B R E A K O U T");
+ const int16_t logoX = x + (w - breakout_width) / 2;
+ const int16_t logoY = y + 15;
+ display->drawXbm(logoX, logoY, breakout_width, breakout_height, breakout);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // The glyph is three brick courses, a ball, and a paddle -- colour each to match the game.
+ const uint16_t abg = graphics::getThemeBodyBg();
+ graphics::registerTFTColorRegionDirect(logoX, logoY + 1, breakout_width, 2, graphics::TFTPalette::Red, abg);
+ graphics::registerTFTColorRegionDirect(logoX, logoY + 4, breakout_width, 2, graphics::TFTPalette::Yellow, abg);
+ graphics::registerTFTColorRegionDirect(logoX, logoY + 7, breakout_width, 2, graphics::TFTPalette::Green, abg);
+ graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 14, 8, 2, graphics::TFTPalette::Blue, abg); // paddle
+ graphics::registerTFTColorRegionDirect(logoX + 7, logoY + 10, 2, 2, graphics::TFTPalette::White, abg); // ball
+#endif
+ char hi[32];
+ if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
+ snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0)));
+ else
+ snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0)));
+ display->drawString(cx, y + 34, hi);
+}
+
+void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+ display->setFont(FONT_SMALL);
+
+ // Score row (top-left), remaining lives as small squares (top-right).
+ char buf[16];
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ snprintf(buf, sizeof(buf), "Sc %lu", static_cast(game.score()));
+ display->drawString(x + 2, y, buf);
+ for (uint8_t i = 0; i < game.lives(); i++)
+ display->fillRect(x + display->getWidth() - 3 - i * 4, y + 2, 2, 2);
+
+ // Bricks.
+ for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++)
+ for (uint8_t c = 0; c < BreakoutGame::BRICK_COLS; c++)
+ if (game.brickAt(r, c))
+ display->fillRect(x + c * BreakoutGame::BRICK_W, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H,
+ BreakoutGame::BRICK_W - 1, BreakoutGame::BRICK_H - 1);
+
+ // Paddle.
+ display->fillRect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, BreakoutGame::PADDLE_H);
+
+ // Ball.
+ display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2);
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's
+ // lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register
+ // last so the ball always wins where it overlaps.
+ const uint16_t bg = graphics::getThemeBodyBg();
+ for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++)
+ graphics::registerTFTColorRegionDirect(x, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, BreakoutGame::BOARD_W,
+ BreakoutGame::BRICK_H - 1, brickRowColor(r), bg);
+ graphics::registerTFTColorRegionDirect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W,
+ BreakoutGame::PADDLE_H, graphics::TFTPalette::Blue, bg);
+ graphics::registerTFTColorRegionDirect(x + game.ballX(), y + game.ballY(), 2, 2, graphics::TFTPalette::White, bg);
+#endif
+}
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/Breakout.h b/src/modules/games/Breakout.h
new file mode 100644
index 0000000000..9c4fe8dfb3
--- /dev/null
+++ b/src/modules/games/Breakout.h
@@ -0,0 +1,138 @@
+#pragma once
+
+#include
+#include
+#include
+
+/**
+ * Pure, self-contained Breakout game logic.
+ *
+ * No Arduino/display dependencies - designed to be unit-tested natively (see test/test_breakout)
+ * and reused by the Breakout adapter below without pulling in the display stack.
+ *
+ * The board is a fixed BOARD_W x BOARD_H pixel field. A grid of bricks sits near the top, a paddle
+ * slides along the bottom, and a ball bounces between them. Ball position/velocity are tracked in
+ * fixed-point sub-pixels (SUBPX per pixel) so movement is smooth and deterministic; everything is
+ * integer math and statically sized (no heap).
+ */
+class BreakoutGame
+{
+ public:
+ // Playfield, in pixels (a standard 128x64 OLED in landscape).
+ static constexpr int16_t BOARD_W = 128;
+ static constexpr int16_t BOARD_H = 64;
+
+ // Brick grid.
+ static constexpr uint8_t BRICK_COLS = 8;
+ static constexpr uint8_t BRICK_ROWS = 5;
+ static constexpr int16_t BRICK_W = BOARD_W / BRICK_COLS; // 16
+ static constexpr int16_t BRICK_H = 4;
+ static constexpr int16_t BRICK_TOP = 12; // top of the first course; leaves room for the score row
+
+ // Paddle.
+ static constexpr int16_t PADDLE_W = 24;
+ static constexpr int16_t PADDLE_H = 2;
+ static constexpr int16_t PADDLE_Y = BOARD_H - 3; // top edge of the paddle
+ static constexpr int16_t PADDLE_STEP = 6; // pixels moved per input event
+
+ static constexpr uint8_t START_LIVES = 3;
+ static constexpr uint8_t POINTS_PER_BRICK = 10;
+
+ /** (Re)start a full game: rebuild bricks, reset lives/score, serve the ball. `seed` drives the
+ * xorshift32 RNG used for the initial serve direction. */
+ void reset(uint32_t seed);
+
+ /** Advance the simulation by one tick (move the ball, resolve collisions). Returns true if the
+ * game is still in progress, false once the last life is lost. No-op returning false once dead. */
+ bool step();
+
+ /** Slide the paddle; the ball is unaffected. moveLeft/moveRight use the default per-press step;
+ * movePaddle takes an explicit signed pixel delta (used when polling a held joystick). */
+ void movePaddle(int16_t dxPx);
+ void moveLeft();
+ void moveRight();
+
+ bool isPlaying() const { return alive; }
+ uint32_t score() const { return points; }
+ uint8_t lives() const { return livesLeft; }
+ uint8_t level() const { return levelNum; }
+ uint16_t bricksRemaining() const { return bricksLeft; }
+
+ // --- Rendering accessors (all in board pixels) ---
+ int16_t paddleX() const { return paddleLeft; }
+ int16_t ballX() const { return static_cast(ballPxX / SUBPX); }
+ int16_t ballY() const { return static_cast(ballPxY / SUBPX); }
+ bool brickAt(uint8_t row, uint8_t col) const { return row < BRICK_ROWS && col < BRICK_COLS && bricks[row][col]; }
+
+ private:
+ static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel
+ static constexpr int32_t BALL_VY = 40; // vertical ball speed (sub-pixels/step)
+ static constexpr int16_t BALL_SIZE = 2; // ball is drawn BALL_SIZE x BALL_SIZE
+
+ void buildBricks();
+ void serveBall();
+ void nextLevel();
+ uint32_t nextRandom();
+
+ uint8_t bricks[BRICK_ROWS][BRICK_COLS] = {0}; // 0 == cleared, 1 == present
+ uint16_t bricksLeft = 0;
+
+ int16_t paddleLeft = 0; // left edge of the paddle, in pixels
+
+ int32_t ballPxX = 0, ballPxY = 0; // ball centre, in sub-pixels
+ int32_t ballVx = 0, ballVy = 0; // ball velocity, in sub-pixels/step
+
+ uint32_t points = 0;
+ uint8_t livesLeft = 0;
+ uint8_t levelNum = 1;
+ uint32_t rng = 1; // xorshift32 state (never 0)
+ bool alive = false;
+ bool ballTick = false; // ball advances on every other step() (see step())
+};
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "Game.h"
+#include "HighScoreTable.h"
+
+/**
+ * Breakout as a hosted Game. Wraps the pure BreakoutGame logic above and supplies the attract art,
+ * the playfield renderer, the paddle input, the level-based speed curve, and its own local
+ * high-score table. No mesh protocol (scores are local-only).
+ */
+class Breakout : public Game
+{
+ public:
+ Breakout();
+
+ const char *name() const override { return "Breakout"; }
+
+ void start(uint32_t seed) override { game.reset(seed); }
+ bool tick() override; // polls a held joystick for the paddle, then advances the ball
+ bool isPlaying() const override { return game.isPlaying(); }
+ uint32_t score() const override { return game.score(); }
+ int32_t tickIntervalMs() const override;
+
+ void handleInput(input_broker_event ev) override;
+
+ void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
+ void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
+
+ HighScoreTableBase &scores() override { return scores_; }
+
+ private:
+ // On-disk high-score record. New file (magic 'BRKT', version 1); layout mirrors Snake's.
+ struct BreakoutEntry {
+ uint32_t score;
+ uint32_t nodeNum;
+ char shortName[5]; // three characters, NUL-terminated
+ uint32_t epoch; // getValidTime(), 0 if no RTC
+ } __attribute__((packed));
+
+ BreakoutGame game;
+ HighScoreTable scores_{"/prefs/breakout.dat", 0x424B5254u, 1, "Breakout"};
+};
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/ChirpyRunner.cpp b/src/modules/games/ChirpyRunner.cpp
new file mode 100644
index 0000000000..4e1a185217
--- /dev/null
+++ b/src/modules/games/ChirpyRunner.cpp
@@ -0,0 +1,303 @@
+#include "ChirpyRunner.h"
+
+// ===========================================================================
+// Pure ChirpyRunnerGame logic (no display/FS dependencies; always compiled)
+// ===========================================================================
+
+uint32_t ChirpyRunnerGame::nextRandom()
+{
+ uint32_t x = rng;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ rng = x;
+ return x;
+}
+
+int16_t ChirpyRunnerGame::pickGapSteps()
+{
+ return static_cast(GAP_STEPS_MIN + static_cast(nextRandom() % (GAP_STEPS_MAX - GAP_STEPS_MIN + 1)));
+}
+
+void ChirpyRunnerGame::resetClouds()
+{
+ // Spread the clouds across the sky at staggered x, at varied heights near the top.
+ for (uint8_t i = 0; i < CLOUD_COUNT; i++) {
+ cloud[i].xSub = static_cast(i * (BOARD_W / CLOUD_COUNT) + 6) * SUBPX;
+ cloud[i].y = static_cast(10 + nextRandom() % 10u); // 10..19 (below the score row)
+ }
+}
+
+void ChirpyRunnerGame::scrollClouds()
+{
+ // Slow parallax drift; wrap back to the right (at a fresh height) once off the left edge.
+ for (uint8_t i = 0; i < CLOUD_COUNT; i++) {
+ cloud[i].xSub -= CLOUD_SPEED_SUB;
+ if (cloud[i].xSub / SUBPX + CLOUD_W < 0) {
+ cloud[i].xSub = static_cast(BOARD_W + nextRandom() % 24u) * SUBPX;
+ cloud[i].y = static_cast(10 + nextRandom() % 10u);
+ }
+ }
+}
+
+void ChirpyRunnerGame::spawnObstacle()
+{
+ for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
+ if (obst[i].active)
+ continue;
+ obst[i].active = true;
+ obst[i].scored = false;
+ obst[i].xSub = static_cast(BOARD_W) * SUBPX;
+ obst[i].w = OBST_W;
+ // Three height tiers so timing varies (kept clearable with margin for a forgiving jump).
+ const uint32_t tier = nextRandom() % 3u;
+ obst[i].h = BUILDING_HEIGHTS[tier];
+ obst[i].colorIdx = static_cast((spawnCount / 10u) % OBST_COLOR_COUNT);
+ spawnCount++;
+ return;
+ }
+}
+
+void ChirpyRunnerGame::reset(uint32_t seed)
+{
+ rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
+ points = 0;
+ alive = true;
+ chirpyTop = groundedTopSub();
+ vy = 0;
+ jumpGravity = GRAVITY;
+ grounded = true;
+ for (uint8_t i = 0; i < MAX_OBSTACLES; i++)
+ obst[i] = {};
+ speedSub = SPEED_BASE;
+ spawnTimer = 0; // first obstacle spawns on the first step
+ spawnCount = 0;
+ resetClouds();
+}
+
+int32_t ChirpyRunnerGame::jumpScaleQ8() const
+{
+ // ratio = current scroll speed / base scroll speed, in Q8 (256 == 1.0).
+ const int32_t ratioQ8 = (speedSub * 256) / SPEED_BASE;
+ // Feed only JUMP_SPEEDUP_PCT of the speed increase into the jump scale: k = 1 + (ratio-1)*pct.
+ const int32_t kQ8 = 256 + ((ratioQ8 - 256) * JUMP_SPEEDUP_PCT) / 100;
+ return kQ8 < 256 ? 256 : kQ8; // never slower than the base hop
+}
+
+void ChirpyRunnerGame::jump()
+{
+ if (!alive || !grounded)
+ return;
+ // Scale velocity by k and gravity by k*k: the apex height is unchanged (Chirpy still clears the
+ // same buildings) but the air-time shrinks by k, so the clearing window tightens with the speed.
+ const int32_t kQ8 = jumpScaleQ8();
+ vy = -(JUMP_V * kQ8) / 256;
+ jumpGravity = (GRAVITY * kQ8 * kQ8) / (256 * 256);
+ if (jumpGravity < GRAVITY)
+ jumpGravity = GRAVITY;
+ grounded = false;
+}
+
+bool ChirpyRunnerGame::step()
+{
+ if (!alive)
+ return false;
+
+ scrollClouds(); // decorative background parallax
+
+ // --- Chirpy vertical motion (gravity latched at launch, so the arc stays consistent mid-air) ---
+ vy += jumpGravity;
+ chirpyTop += vy;
+ const int32_t gt = groundedTopSub();
+ if (chirpyTop >= gt) {
+ chirpyTop = gt;
+ vy = 0;
+ grounded = true;
+ } else {
+ grounded = false;
+ }
+
+ // --- Scroll obstacles, score, retire off-screen ones ---
+ for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
+ if (!obst[i].active)
+ continue;
+ obst[i].xSub -= speedSub;
+ const int16_t ox = obstacleX(i);
+ if (!obst[i].scored && ox + obst[i].w < CHIRPY_X) {
+ obst[i].scored = true;
+ points++;
+ }
+ if (ox + obst[i].w < 0)
+ obst[i].active = false;
+ }
+
+ // --- Spawn on a tick timer (time-based spacing that scales with speed) ---
+ if (spawnTimer > 0)
+ spawnTimer--;
+ if (spawnTimer <= 0) {
+ spawnObstacle();
+ spawnTimer = pickGapSteps();
+ }
+
+ // --- Difficulty ramp (scroll speed grows with score, then caps) ---
+ const uint32_t capped = points < SPEED_CAP_PTS ? points : SPEED_CAP_PTS;
+ speedSub = SPEED_BASE + static_cast(capped) * SPEED_INC;
+
+ // --- Collision (forgiving hitbox: skip the antenna, inset the sides) ---
+ const int16_t hx = CHIRPY_X + 2;
+ const int16_t hxr = hx + (CHIRPY_W - 4);
+ const int16_t hBottom = chirpyY() + CHIRPY_H;
+ const int16_t hTop = chirpyY() + 4;
+ for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
+ if (!obst[i].active)
+ continue;
+ const int16_t ox = obstacleX(i);
+ const int16_t oxr = ox + obst[i].w;
+ const int16_t oTop = GROUND_Y - obst[i].h;
+ if (hx < oxr && hxr > ox && hTop < GROUND_Y && hBottom > oTop) {
+ alive = false;
+ return false;
+ }
+ }
+
+ return alive;
+}
+
+// ===========================================================================
+// ChirpyRunner adapter (display + persistence; BaseUI games build only)
+// ===========================================================================
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "graphics/Screen.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
+#include "graphics/images.h"
+#include "main.h"
+
+ChirpyRunner::ChirpyRunner()
+{
+ scores_.load();
+}
+
+void ChirpyRunner::handleInput(input_broker_event ev)
+{
+ // SELECT is the jump (as requested); UP is accepted as a convenient alternate.
+ if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP)
+ game.jump();
+}
+
+void ChirpyRunner::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+ const int16_t w = display->getWidth();
+ const int16_t cx = x + w / 2;
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(cx, y, "CHIRPY DASH");
+ const int16_t logoX = x + (w - chirpy_run_width) / 2;
+ const int16_t logoY = y + 15;
+ display->drawXbm(logoX, logoY, chirpy_run_width, chirpy_run_height, chirpy_run);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Chirpy is green, with white eyes. The eyes are the lit pixels at rows 5-7, cols 4-7 of the
+ // glyph; a white region registered after the green one wins there.
+ graphics::registerTFTColorRegionDirect(logoX, logoY, chirpy_run_width, chirpy_run_height,
+ graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg());
+ graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg());
+#endif
+ char hi[32];
+ if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
+ snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0)));
+ else
+ snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0)));
+ display->drawString(cx, y + 34, hi);
+}
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+// Obstacle colour palette; the game logic advances the index every 10 spawns.
+static uint16_t obstacleColor(uint8_t idx)
+{
+ using namespace graphics;
+ switch (idx) {
+ case 0:
+ return TFTPalette::Red;
+ case 1:
+ return TFTPalette::Orange;
+ case 2:
+ return TFTPalette::Yellow;
+ case 3:
+ return TFTPalette::Magenta;
+ case 4:
+ return TFTPalette::Cyan;
+ default:
+ return TFTPalette::Blue;
+ }
+}
+#endif
+
+void ChirpyRunner::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+ display->setFont(FONT_SMALL);
+
+ // Clouds drifting in the background (drawn first so everything else sits in front).
+ for (uint8_t i = 0; i < ChirpyRunnerGame::cloudSlots(); i++) {
+ const int16_t cxp = x + game.cloudX(i);
+ const int16_t cyp = y + game.cloudY(i);
+ display->fillRect(cxp + 2, cyp, 4, 1);
+ display->fillRect(cxp + 1, cyp + 1, 6, 1);
+ display->fillRect(cxp, cyp + 2, 8, 1);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ graphics::registerTFTColorRegionDirect(cxp, cyp, 8, 3, graphics::TFTPalette::LightGray, graphics::getThemeBodyBg());
+#endif
+ }
+
+ // Score (top-left).
+ char buf[16];
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ snprintf(buf, sizeof(buf), "Sc %lu", static_cast(game.score()));
+ display->drawString(x + 2, y, buf);
+
+ // Ground line.
+ display->drawLine(x, y + ChirpyRunnerGame::GROUND_Y, x + display->getWidth() - 1, y + ChirpyRunnerGame::GROUND_Y);
+
+ // Obstacles drawn as little buildings: a solid tower with two columns of punched-out windows
+ // (dark holes). On colour displays the walls cycle colour every 10 spawns and the windows glow
+ // (they are the region's off-pixels, so they take the off-colour).
+ for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) {
+ if (!game.obstacleActive(i))
+ continue;
+ const int16_t oh = game.obstacleH(i);
+ const int16_t ow = game.obstacleW(i);
+ const int16_t oxp = x + game.obstacleX(i);
+ const int16_t oyp = y + ChirpyRunnerGame::GROUND_Y - oh;
+
+ display->setColor(WHITE);
+ display->fillRect(oxp, oyp, ow, oh);
+ // Windows: 1px holes in the left and right columns, every other row, skipping the roof row
+ // and the ground-floor rows so the tower reads as a building.
+ display->setColor(BLACK);
+ for (int16_t wy = oyp + 2; wy <= oyp + oh - 3; wy += 2) {
+ display->fillRect(oxp + 1, wy, 1, 1);
+ display->fillRect(oxp + ow - 2, wy, 1, 1);
+ }
+ display->setColor(WHITE);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ graphics::registerTFTColorRegionDirect(oxp, oyp, ow, oh, obstacleColor(game.obstacleColorIndex(i)),
+ graphics::TFTPalette::White); // lit windows
+#endif
+ }
+
+ // Chirpy.
+ const int16_t cxp = x + ChirpyRunnerGame::CHIRPY_X;
+ const int16_t cyp = y + game.chirpyY();
+ display->drawXbm(cxp, cyp, chirpy_run_width, chirpy_run_height, chirpy_run);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ graphics::registerTFTColorRegionDirect(cxp, cyp, chirpy_run_width, chirpy_run_height, graphics::TFTPalette::MeshtasticGreen,
+ graphics::getThemeBodyBg());
+ graphics::registerTFTColorRegionDirect(cxp + 4, cyp + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg());
+#endif
+}
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/ChirpyRunner.h b/src/modules/games/ChirpyRunner.h
new file mode 100644
index 0000000000..b86e1d7757
--- /dev/null
+++ b/src/modules/games/ChirpyRunner.h
@@ -0,0 +1,177 @@
+#pragma once
+
+#include
+#include
+#include
+
+/**
+ * Pure, self-contained Chirpy Runner game logic (a dino-runner / flappy-style side-scroller).
+ *
+ * No Arduino/display dependencies - designed to be unit-tested natively (see test/test_chirpy)
+ * and reused by the ChirpyRunner adapter below without pulling in the display stack.
+ *
+ * Chirpy runs in place at a fixed x on the ground; obstacles scroll in from the right and the
+ * player presses jump (SELECT) to hop over them. Gravity pulls Chirpy back down. Clearing an
+ * obstacle scores a point and nudges the scroll speed up. A collision ends the run. Chirpy's
+ * vertical motion is tracked in fixed-point sub-pixels (SUBPX per pixel); everything is integer
+ * math and statically sized (no heap).
+ */
+class ChirpyRunnerGame
+{
+ public:
+ // Playfield, in pixels (a standard 128x64 OLED in landscape).
+ static constexpr int16_t BOARD_W = 128;
+ static constexpr int16_t BOARD_H = 64;
+
+ // Chirpy sprite box and fixed horizontal position; GROUND_Y is where his feet rest.
+ static constexpr int16_t CHIRPY_W = 12;
+ static constexpr int16_t CHIRPY_H = 16;
+ static constexpr int16_t CHIRPY_X = 14;
+ static constexpr int16_t GROUND_Y = BOARD_H - 2; // 62
+
+ static constexpr uint8_t MAX_OBSTACLES = 4;
+ static constexpr int16_t OBST_W = 6;
+ static constexpr uint8_t OBST_COLOR_COUNT = 6; // obstacle colour advances every 10 spawns
+ static constexpr uint8_t CLOUD_COUNT = 3; // decorative background clouds
+
+ /** (Re)start a run: Chirpy on the ground, no obstacles yet, score 0. `seed` drives the
+ * xorshift32 RNG used for obstacle heights and spacing. */
+ void reset(uint32_t seed);
+
+ /** Advance one tick (gravity + scroll + spawn + collision). Returns true while the run is in
+ * progress, false once Chirpy hits an obstacle. No-op returning false once dead. */
+ bool step();
+
+ /** Hop, if Chirpy is on the ground (single jump; ignored while airborne). */
+ void jump();
+
+ bool isPlaying() const { return alive; }
+ uint32_t score() const { return points; }
+ bool onGround() const { return grounded; }
+
+ // --- Rendering accessors (board pixels) ---
+ int16_t chirpyY() const { return static_cast(chirpyTop / SUBPX); } // sprite top
+ static constexpr uint8_t obstacleSlots() { return MAX_OBSTACLES; }
+ bool obstacleActive(uint8_t i) const { return i < MAX_OBSTACLES && obst[i].active; }
+ int16_t obstacleX(uint8_t i) const { return static_cast(obst[i].xSub / SUBPX); }
+ uint8_t obstacleW(uint8_t i) const { return obst[i].w; }
+ uint8_t obstacleH(uint8_t i) const { return obst[i].h; }
+ uint8_t obstacleColorIndex(uint8_t i) const { return obst[i].colorIdx; }
+ static constexpr uint8_t cloudSlots() { return CLOUD_COUNT; }
+ int16_t cloudX(uint8_t i) const { return static_cast(cloud[i].xSub / SUBPX); }
+ int16_t cloudY(uint8_t i) const { return cloud[i].y; }
+
+ private:
+ static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel
+ static constexpr int32_t GRAVITY = 7; // downward accel (sub-px/step^2)
+ static constexpr int32_t JUMP_V = 75; // initial upward velocity on a hop (sub-px/step)
+ static constexpr int32_t SPEED_BASE = 32; // base scroll speed (sub-px/step == 2 px)
+ static constexpr int32_t SPEED_INC = 2; // speed added per point scored
+ static constexpr uint32_t SPEED_CAP_PTS = 40; // score at which the speed ramp caps (== 5 px/step)
+ // Obstacle spacing is measured in TICKS, not pixels, so the time between obstacles stays
+ // constant as the scroll speed ramps up -- otherwise a fixed pixel gap collapses into fewer
+ // ticks at high speed until it drops below the jump duration and clearing becomes impossible.
+ // The min is kept just above the ~22-tick jump so obstacles come in a tight but landable rhythm.
+ static constexpr int16_t GAP_STEPS_MIN = 23; // min ticks between spawns (> jump duration)
+ static constexpr int16_t GAP_STEPS_MAX = 40; // max ticks between spawns
+
+ // Chirpy's hop scales with the scroll speed. A fixed-length jump actually makes the game EASIER
+ // as the buildings speed up: his "high enough to clear" window stays ~constant while the time a
+ // building spends crossing his x-range shrinks (width / speed). Scaling the launch velocity by k
+ // and gravity by k*k keeps the apex height identical (so he still clears the same buildings)
+ // while cutting the air-time by k, which shrinks the clearing window in step with the world.
+ // JUMP_SPEEDUP_PCT is how much of the scroll-speed increase feeds into k:
+ // 100 == fully proportional (difficulty stays flat), lower == Chirpy speeds up sub-linearly
+ // (so it still eases off slightly at high speed), higher == it gets harder.
+ static constexpr int32_t JUMP_SPEEDUP_PCT = 50;
+
+ static constexpr int8_t BUILDING_HEIGHTS[] = {8, 12, 16}; // three tiers of obstacle heights (pixels)
+
+ static constexpr int32_t CLOUD_SPEED_SUB = 6; // background scroll speed (sub-px/step, slow parallax)
+ static constexpr int16_t CLOUD_W = 8; // cloud puff width (for off-screen wrap)
+
+ struct Obstacle {
+ int32_t xSub; // left edge, sub-pixels
+ uint8_t w;
+ uint8_t h;
+ uint8_t colorIdx; // which colour batch (advances every 10 spawns)
+ bool active;
+ bool scored;
+ };
+
+ struct Cloud {
+ int32_t xSub; // left edge, sub-pixels
+ int16_t y; // top, pixels
+ };
+
+ int32_t groundedTopSub() const { return static_cast(GROUND_Y - CHIRPY_H) * SUBPX; }
+ // Jump scale factor k for the current scroll speed, in Q8 fixed point (256 == 1.0 == base speed).
+ int32_t jumpScaleQ8() const;
+ uint32_t nextRandom();
+ void spawnObstacle();
+ int16_t pickGapSteps();
+ void resetClouds();
+ void scrollClouds();
+
+ int32_t chirpyTop = 0; // sprite-top y, sub-pixels
+ int32_t vy = 0; // vertical velocity, sub-pixels/step
+ int32_t jumpGravity = GRAVITY; // gravity for the current hop (latched at launch, scaled by k*k)
+ bool grounded = true;
+
+ Obstacle obst[MAX_OBSTACLES] = {};
+ Cloud cloud[CLOUD_COUNT] = {};
+ int32_t speedSub = SPEED_BASE;
+ int16_t spawnTimer = 0; // ticks until the next obstacle spawns
+ uint32_t spawnCount = 0; // total obstacles spawned (drives the colour cycle)
+
+ uint32_t points = 0;
+ uint32_t rng = 1; // xorshift32 state (never 0)
+ bool alive = false;
+};
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "Game.h"
+#include "HighScoreTable.h"
+
+/**
+ * Chirpy Runner as a hosted Game. Wraps the pure logic above and supplies the attract art, the
+ * side-scroller renderer (Chirpy sprite + obstacles + ground), the jump input, and its own
+ * local high-score table. No mesh protocol (scores are local-only).
+ */
+class ChirpyRunner : public Game
+{
+ public:
+ ChirpyRunner();
+
+ const char *name() const override { return "Chirpy Dash"; }
+
+ void start(uint32_t seed) override { game.reset(seed); }
+ bool tick() override { return game.step(); }
+ bool isPlaying() const override { return game.isPlaying(); }
+ uint32_t score() const override { return game.score(); }
+ int32_t tickIntervalMs() const override { return 33; } // ~30 fps; difficulty ramps via scroll speed
+
+ void handleInput(input_broker_event ev) override;
+
+ void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
+ void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
+
+ HighScoreTableBase &scores() override { return scores_; }
+
+ private:
+ // On-disk high-score record. New file (magic 'CHRP', version 1); layout mirrors Snake's.
+ struct ChirpyEntry {
+ uint32_t score;
+ uint32_t nodeNum;
+ char shortName[5]; // three characters, NUL-terminated
+ uint32_t epoch; // getValidTime(), 0 if no RTC
+ } __attribute__((packed));
+
+ ChirpyRunnerGame game;
+ HighScoreTable scores_{"/prefs/chirpy.dat", 0x43485250u, 1, "Chirpy"};
+};
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/Game.h b/src/modules/games/Game.h
new file mode 100644
index 0000000000..751b2d6b5c
--- /dev/null
+++ b/src/modules/games/Game.h
@@ -0,0 +1,57 @@
+#pragma once
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "HighScoreTable.h"
+#include "input/InputBroker.h" // input_broker_event
+#include "mesh/MeshModule.h" // ProcessMessage, meshtastic_MeshPacket
+#include
+
+class OLEDDisplay;
+class OLEDDisplayUiState;
+class GamesModule;
+
+/**
+ * A single game hosted by GamesModule. The host owns the shared UI state machine (attract /
+ * playing / paused / game-over / high-scores), the initials picker, high-score persistence calls,
+ * the tick thread, and the mesh port; each Game supplies only the game-specific pieces: its
+ * attract art, its playfield, its per-key input while playing, its speed curve, and its own
+ * high-score table (and, optionally, a mesh announce/receive protocol).
+ */
+class Game
+{
+ public:
+ virtual ~Game() = default;
+
+ virtual const char *name() const = 0;
+
+ // --- Lifecycle ---
+ virtual void start(uint32_t seed) = 0; // (re)start the underlying game logic
+ virtual bool tick() = 0; // advance one step; returns isPlaying() afterwards
+ virtual bool isPlaying() const = 0;
+ virtual uint32_t score() const = 0;
+ virtual int32_t tickIntervalMs() const = 0; // per-game speed curve
+
+ // --- Input while PLAYING (the host handles the BACK-to-pause and menu keys) ---
+ virtual void handleInput(input_broker_event ev) = 0;
+
+ // --- Rendering (the host draws the shared PAUSED / GAME OVER / HIGH SCORES chrome) ---
+ virtual void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) = 0; // title/art + hi + hint
+ virtual void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) = 0; // playfield only
+ virtual const char *gameOverHint() const { return "SELECT: scores"; }
+
+ // --- High scores (the host runs the initials picker + save) ---
+ virtual HighScoreTableBase &scores() = 0;
+
+ // --- Mesh (defaults are no-ops; only games with a wire protocol override these) ---
+ // Note: the new-high-score announcement is NOT here -- it is shared by every game and lives in
+ // GamesModule (which splices name() into one common message).
+ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) { return ProcessMessage::CONTINUE; }
+ virtual bool wantsPeriodicMesh() const { return false; }
+ // Perform any due periodic broadcast and return ms until the next one (-1 == nothing pending).
+ virtual int32_t meshTick(GamesModule &host) { return -1; }
+};
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp
new file mode 100644
index 0000000000..1d72ba632a
--- /dev/null
+++ b/src/modules/games/GamesModule.cpp
@@ -0,0 +1,373 @@
+#include "GamesModule.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "Breakout.h"
+#include "ChirpyRunner.h"
+#include "PowerFSM.h"
+#include "Snake.h"
+#include "Tetris.h"
+#include "graphics/Screen.h"
+#include "graphics/ScreenFonts.h"
+#include "main.h"
+#include "mesh/NodeDB.h"
+#if GAMES_ANNOUNCE_HIGH_SCORE
+#include "MeshService.h"
+#endif
+
+GamesModule *gamesModule;
+
+GamesModule::GamesModule() : SinglePortModule("games", meshtastic_PortNum_PRIVATE_APP), concurrency::OSThread("Games")
+{
+ // Register the hosted games. Order sets the attract-screen cycle order (Snake is shown first).
+ games.push_back(new Snake());
+ games.push_back(new Tetris());
+ games.push_back(new ChirpyRunner());
+ games.push_back(new Breakout());
+ inputObserver.observe(inputBroker);
+
+ // Keep the tick thread alive at boot only if a game broadcasts periodically; otherwise idle
+ // until the player launches a game. The first idle tick reschedules to the real cadence.
+ bool periodic = false;
+ for (Game *g : games)
+ periodic = periodic || g->wantsPeriodicMesh();
+ if (periodic)
+ setIntervalFromNow(1000);
+ else
+ disable();
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle / state transitions
+// ---------------------------------------------------------------------------
+
+void GamesModule::launchGame()
+{
+ if (games.empty())
+ return;
+ // The games frame is already current (the player is on the attract screen), so just begin play
+ // with the selected game -- no focus change or frameset regeneration needed.
+ active = games[selected];
+ startPlaying();
+}
+
+void GamesModule::startPlaying()
+{
+ active->start(static_cast(random()) ^ millis());
+ uiState = GAMES_PLAYING;
+ lastAwakeKickMs = millis();
+ kickTick();
+ requestRedraw();
+}
+
+void GamesModule::enterGameOver()
+{
+ lastScore = active ? active->score() : 0;
+ lastRank = -1;
+ lastWasNewTop = false;
+ uiState = GAMES_GAMEOVER;
+
+ // Arcade-style: if the score placed, prompt for initials, then record it in the picker's
+ // callback. Otherwise just show the game-over screen.
+ if (active && active->scores().qualifies(lastScore))
+ promptForInitials();
+
+ requestRedraw();
+}
+
+void GamesModule::promptForInitials()
+{
+ screen->showAlphanumericPicker("New High Score!\nEnter initials", "AAA", 60000, HighScoreTableBase::INITIALS_LEN,
+ [this](const std::string &initials) { this->recordHighScore(initials.c_str()); });
+}
+
+void GamesModule::recordHighScore(const char *initials)
+{
+ if (!active)
+ return;
+ bool isNewTop = false;
+ lastRank = active->scores().insert(lastScore, initials, nodeDB ? nodeDB->getNodeNum() : 0, isNewTop);
+ lastWasNewTop = isNewTop;
+ if (lastRank >= 0)
+ active->scores().save(); // table changed -- the only time we write flash
+#if GAMES_ANNOUNCE_HIGH_SCORE
+ if (isNewTop && lastScore > 0)
+ announceHighScore(initials, lastScore);
+#endif
+ requestRedraw();
+}
+
+#if GAMES_ANNOUNCE_HIGH_SCORE
+void GamesModule::announceHighScore(const char *initials, uint32_t score)
+{
+ if (!active || !service)
+ return;
+ if (!initials || initials[0] == '\0')
+ return;
+ meshtastic_MeshPacket *p = allocDataPacket();
+ p->to = NODENUM_BROADCAST;
+ p->channel = 0; // primary channel
+ p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
+ // One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu
+ // if a receiving node's font lacks a glyph.
+ p->decoded.payload.size = snprintf(reinterpret_cast(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes),
+ GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast(score));
+ service->sendToMesh(p);
+ LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast(score));
+}
+#endif
+
+void GamesModule::exitToIdle()
+{
+ uiState = GAMES_IDLE;
+ active = nullptr;
+ // The games frame is always present, so we just return it to the attract screen and redraw --
+ // no frameset change. interceptingKeyboardInput() now returns false, so the D-pad navigates
+ // between frames again. Keep ticking only if a game still needs its periodic broadcast.
+ bool periodic = false;
+ for (Game *g : games)
+ periodic = periodic || g->wantsPeriodicMesh();
+ if (periodic)
+ setIntervalFromNow(1000);
+ else
+ disable();
+ requestRedraw();
+}
+
+void GamesModule::requestRedraw()
+{
+ UIFrameEvent e;
+ e.action = UIFrameEvent::Action::REDRAW_ONLY;
+ notifyObservers(&e);
+}
+
+void GamesModule::kickTick()
+{
+ enabled = true;
+ setIntervalFromNow(250); // brief beat so the player sees the board before it moves
+}
+
+int32_t GamesModule::runOnce()
+{
+ if (uiState == GAMES_PLAYING && active) {
+ if (!active->tick()) {
+ enterGameOver();
+ return disable();
+ }
+
+ // Keep the display awake through long runs that generate no key presses.
+ const uint32_t now = millis();
+ if (now - lastAwakeKickMs > 1500) {
+ powerFSM.trigger(EVENT_PRESS);
+ lastAwakeKickMs = now;
+ }
+
+ requestRedraw();
+ return active->tickIntervalMs();
+ }
+
+ // Idle: service any game that broadcasts periodically; sleep until the soonest one is due.
+ int32_t next = -1;
+ for (Game *g : games) {
+ const int32_t due = g->meshTick(*this);
+ if (due >= 0 && (next < 0 || due < next))
+ next = due;
+ }
+ return next < 0 ? disable() : next;
+}
+
+// ---------------------------------------------------------------------------
+// Input
+// ---------------------------------------------------------------------------
+
+int GamesModule::handleInputEvent(const InputEvent *event)
+{
+ // Ignore all input unless the games frame is the one actually on screen -- otherwise the attract
+ // screen's UP/DOWN would hijack normal frame navigation from wherever the player happens to be.
+ if (!screen || !screen->isGamesFrameShown())
+ return 0;
+ if (screen->isOverlayBannerShowing())
+ return 0; // a menu banner is up; don't steal its input
+
+ const input_broker_event ev = event->inputEvent;
+ const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK);
+
+ switch (uiState) {
+ case GAMES_IDLE:
+ // Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it;
+ // long-press SELECT opens that game's high-score table. Everything else passes through so
+ // the D-pad still navigates between frames.
+ if (!games.empty() && (ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_UP)) {
+ const uint8_t n = static_cast(games.size());
+ selected = (ev == INPUT_BROKER_DOWN) ? (selected + 1) % n : (selected + n - 1) % n;
+ requestRedraw();
+ return 1;
+ }
+ if (ev == INPUT_BROKER_SELECT_LONG && !games.empty()) {
+ active = games[selected];
+ uiState = GAMES_HISCORES;
+ requestRedraw();
+ return 1;
+ }
+ return 0;
+
+ case GAMES_PLAYING:
+ if (isBack) {
+ uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit
+ disable();
+ requestRedraw();
+ } else if (active) {
+ active->handleInput(ev);
+ if (!active->isPlaying()) {
+ enterGameOver();
+ return 1;
+ }
+ requestRedraw();
+ }
+ return 1;
+
+ case GAMES_PAUSED:
+ if (isBack) {
+ exitToIdle(); // quit from pause
+ } else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT ||
+ ev == INPUT_BROKER_RIGHT) {
+ uiState = GAMES_PLAYING;
+ kickTick();
+ requestRedraw();
+ }
+ return 1;
+
+ case GAMES_GAMEOVER:
+ if (ev == INPUT_BROKER_SELECT) {
+ uiState = GAMES_HISCORES;
+ requestRedraw();
+ } else if (isBack) {
+ exitToIdle();
+ }
+ return 1;
+
+ case GAMES_HISCORES:
+ if (ev == INPUT_BROKER_SELECT_LONG && active) {
+ static const char *opts[] = {"No", "Yes"};
+ graphics::BannerOverlayOptions confirm;
+ confirm.message = "Clear Scores?";
+ confirm.optionsArrayPtr = opts;
+ confirm.optionsCount = 2;
+ confirm.bannerCallback = [this](int sel) {
+ if (sel == 1 && active) {
+ active->scores().clear();
+ active->scores().save();
+ requestRedraw();
+ LOG_INFO("Games: high scores cleared");
+ }
+ };
+ if (screen)
+ screen->showOverlayBanner(confirm);
+ } else if (ev == INPUT_BROKER_SELECT || isBack) {
+ exitToIdle();
+ }
+ return 1;
+
+ default:
+ return 0;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+void GamesModule::drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count)
+{
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ const int16_t lineH = FONT_HEIGHT_SMALL;
+ const int16_t total = static_cast(count) * lineH;
+ int16_t startY = y + (display->getHeight() - total) / 2;
+ if (startY < y)
+ startY = y;
+ const int16_t cx = x + display->getWidth() / 2;
+ for (uint8_t i = 0; i < count; i++)
+ display->drawString(cx, startY + i * lineH, lines[i]);
+}
+
+void GamesModule::drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores)
+{
+ display->setFont(FONT_SMALL);
+ display->setColor(WHITE);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->drawString(x, y, "HIGH SCORES");
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ const int16_t rowH = (display->getHeight() - FONT_HEIGHT_SMALL) / HighScoreTableBase::HS_COUNT;
+ for (uint8_t i = 0; i < HighScoreTableBase::HS_COUNT; i++) {
+ char row[32];
+ if (scores.scoreAt(i) > 0) {
+ snprintf(row, sizeof(row), "%u. %-4s %lu", static_cast(i + 1), scores.nameAt(i),
+ static_cast(scores.scoreAt(i)));
+ } else {
+ snprintf(row, sizeof(row), "%u. ---", static_cast(i + 1));
+ }
+ display->drawString(x + 6, y + FONT_HEIGHT_SMALL + i * rowH, row);
+ }
+}
+
+void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+
+ switch (uiState) {
+ case GAMES_IDLE:
+ if (!games.empty())
+ games[selected]->drawAttract(display, x, y);
+ break;
+
+ case GAMES_PLAYING:
+ if (active)
+ active->drawPlaying(display, x, y);
+ break;
+
+ case GAMES_PAUSED:
+ if (active) {
+ active->drawPlaying(display, x, y);
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(x + display->getWidth() / 2, y + display->getHeight() / 2 - FONT_HEIGHT_SMALL / 2, "- PAUSED -");
+ }
+ break;
+
+ case GAMES_GAMEOVER: {
+ char scoreLine[24];
+ snprintf(scoreLine, sizeof(scoreLine), "Score: %lu", static_cast(lastScore));
+ const char *status = lastWasNewTop ? "NEW HIGH SCORE!" : (lastRank >= 0 ? "You made the top 5!" : "");
+ const char *hint = active ? active->gameOverHint() : "SELECT: scores";
+ const char *lines[] = {"GAME OVER", scoreLine, status, hint};
+ drawCenteredLines(display, x, y, lines, 4);
+ break;
+ }
+
+ case GAMES_HISCORES:
+ if (active)
+ drawHighScores(display, x, y, active->scores());
+ break;
+
+ default:
+ break;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mesh receive - dispatch to each hosted game
+// ---------------------------------------------------------------------------
+
+ProcessMessage GamesModule::handleReceived(const meshtastic_MeshPacket &mp)
+{
+ for (Game *g : games) {
+ const ProcessMessage r = g->handleReceived(mp);
+ if (r != ProcessMessage::CONTINUE)
+ return r;
+ }
+ requestRedraw(); // a merged remote score should show up if the high-score screen is open
+ return ProcessMessage::CONTINUE;
+}
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/GamesModule.h b/src/modules/games/GamesModule.h
new file mode 100644
index 0000000000..eb4642f196
--- /dev/null
+++ b/src/modules/games/GamesModule.h
@@ -0,0 +1,101 @@
+#pragma once
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "Game.h"
+#include "Observer.h"
+#include "concurrency/OSThread.h"
+#include "input/InputBroker.h"
+#include "mesh/SinglePortModule.h"
+#include
+
+// Broadcasting a new all-time #1 to the mesh is a COMPILE-TIME option, default OFF. Spending shared
+// airtime must be opted into at build time with -DGAMES_ANNOUNCE_HIGH_SCORE=1; when disabled (the
+// default) the announcement code is compiled out entirely -- there is no runtime toggle. The
+// announcement is shared by every hosted game, with the game's name() spliced into the message.
+#ifndef GAMES_ANNOUNCE_HIGH_SCORE
+#define GAMES_ANNOUNCE_HIGH_SCORE 0
+#endif
+
+#ifndef GAMES_HIGH_SCORE_STRING
+#define GAMES_HIGH_SCORE_STRING "New %s high score %lu by %s!"
+#endif
+
+enum GamesUiState : uint8_t {
+ GAMES_IDLE, // attract screen of the selected game; OSThread idle (unless a game broadcasts)
+ GAMES_PLAYING, // active game running; tick thread ticking
+ GAMES_PAUSED, // paused mid-game
+ GAMES_GAMEOVER, // final score / new-high notice
+ GAMES_HISCORES, // top-5 table of the active/selected game
+};
+
+/**
+ * The single host for all BaseUI games. It owns the always-present "games" frame (drawn through
+ * Screen's trampoline right after home), the shared UI state machine, the game-tick OSThread, the
+ * initials picker + high-score persistence flow, and the PRIVATE_APP mesh port. Individual games
+ * are self-contained Game subclasses registered in the constructor (see src/modules/games/); the
+ * attract screen cycles between them with UP/DOWN and SELECT plays the shown game.
+ */
+class GamesModule : public SinglePortModule, public Observable, private concurrency::OSThread
+{
+ public:
+ GamesModule();
+
+ /// Start the currently-selected game (invoked when SELECT is pressed on the games frame). The
+ /// games frame is already current, so this just begins play.
+ void launchGame();
+
+ // Drawn through the games-frame trampoline, and queried by Screen's input gating / nav-bar, so
+ // these are public. While a game is active we own the D-pad; on the attract screen the D-pad
+ // cycles games (UP/DOWN) and otherwise navigates between frames as usual.
+ void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+ virtual bool interceptingKeyboardInput() override { return uiState != GAMES_IDLE; }
+
+ /// Mesh passthrough for hosted games (a Game is not itself a MeshModule).
+ meshtastic_MeshPacket *gameAllocDataPacket() { return allocDataPacket(); }
+
+ protected:
+ virtual int32_t runOnce() override; // game tick + idle mesh scheduling
+ virtual Observable *getUIFrameObservable() override { return this; }
+ virtual bool wantUIFrame() override { return false; } // shares the games frame; no own slot
+ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
+
+ private:
+ int handleInputEvent(const InputEvent *event);
+ CallbackObserver inputObserver =
+ CallbackObserver(this, &GamesModule::handleInputEvent);
+
+ // === State transitions ===
+ void startPlaying();
+ void enterGameOver();
+ void exitToIdle();
+ void requestRedraw();
+ void kickTick();
+
+ // === Shared game-over / high-score flow ===
+ void promptForInitials();
+ void recordHighScore(const char *initials);
+#if GAMES_ANNOUNCE_HIGH_SCORE
+ // Broadcast a new all-time #1 as a plain text message, shared by every game (name() spliced in).
+ void announceHighScore(const char *initials, uint32_t score);
+#endif
+
+ // === Shared rendering ===
+ void drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count);
+ void drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores);
+
+ std::vector games;
+ uint8_t selected = 0; // attract-screen cursor (index into games)
+ Game *active = nullptr; // game currently playing / whose scores are shown; null when idle
+ GamesUiState uiState = GAMES_IDLE;
+ uint32_t lastScore = 0; // score of the just-finished game (for the GAME OVER screen)
+ int lastRank = -1; // rank achieved last game (-1 == didn't place)
+ bool lastWasNewTop = false; // last game set a new all-time #1
+ uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs
+};
+
+extern GamesModule *gamesModule;
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/HighScoreTable.h b/src/modules/games/HighScoreTable.h
new file mode 100644
index 0000000000..b833ed05a2
--- /dev/null
+++ b/src/modules/games/HighScoreTable.h
@@ -0,0 +1,176 @@
+#pragma once
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "DebugConfiguration.h"
+#include "FSCommon.h"
+#include "SPILock.h"
+#include "SafeFile.h"
+#include "concurrency/LockGuard.h"
+#include "gps/RTC.h"
+#include "mesh/NodeDB.h" // owner (short-name fallback)
+#include
+#include
+#include
+#include
+
+/**
+ * Non-templated view of a game's top-N high-score table, so the shared games UI (attract line,
+ * high-score screen) and the Game interface can talk to any game's table without knowing its
+ * on-disk record layout.
+ */
+class HighScoreTableBase
+{
+ public:
+ static constexpr uint8_t HS_COUNT = 5; // entries kept per game
+ static constexpr uint8_t INITIALS_LEN = 3; // arcade-style initials captured per high score
+
+ virtual ~HighScoreTableBase() = default;
+
+ virtual uint32_t scoreAt(uint8_t i) const = 0;
+ virtual const char *nameAt(uint8_t i) const = 0;
+
+ // True if `score` would place on the sorted-descending table (peek; no mutation).
+ virtual bool qualifies(uint32_t score) const = 0;
+ // Insert under `initials` with the given `nodeNum` (0 == local, or a foreign node for merged
+ // remote scores). Returns the 0-based rank if it placed, else -1; isNewTop set on the #1 slot.
+ virtual int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) = 0;
+ virtual void clear() = 0;
+
+ virtual void load() = 0;
+ virtual void save() = 0;
+ virtual bool loaded() const = 0;
+};
+
+/**
+ * Templated top-N table shared by every game. The algorithm (qualify / insert / load / save / CRC)
+ * is identical across games; only the on-disk record layout differs, so `Entry` is a template
+ * parameter. Each game supplies its own packed `Entry` (with `score`, `shortName[5]`, `nodeNum`,
+ * `epoch` fields, in whatever byte order its existing save file used) plus a file path, magic, and
+ * version -- so pre-existing save files keep loading byte-for-byte after the refactor.
+ */
+template class HighScoreTable : public HighScoreTableBase
+{
+ public:
+ HighScoreTable(const char *path, uint32_t magic, uint8_t version, const char *logTag)
+ : path_(path), magic_(magic), version_(version), logTag_(logTag)
+ {
+ }
+
+ uint32_t scoreAt(uint8_t i) const override { return entries_[i].score; }
+ const char *nameAt(uint8_t i) const override { return entries_[i].shortName; }
+ const Entry &entryAt(uint8_t i) const { return entries_[i]; }
+
+ bool qualifies(uint32_t score) const override
+ {
+ if (score == 0)
+ return false;
+ for (int i = 0; i < HS_COUNT; i++) {
+ if (score > entries_[i].score)
+ return true;
+ }
+ return false;
+ }
+
+ int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) override
+ {
+ isNewTop = false;
+ if (score == 0)
+ return -1;
+
+ int pos = -1;
+ for (int i = 0; i < HS_COUNT; i++) {
+ if (score > entries_[i].score) {
+ pos = i;
+ break;
+ }
+ }
+ if (pos < 0)
+ return -1; // not good enough to place
+
+ for (int i = HS_COUNT - 1; i > pos; i--)
+ entries_[i] = entries_[i - 1];
+
+ Entry &e = entries_[pos];
+ memset(&e, 0, sizeof(e));
+ e.score = score;
+ e.nodeNum = nodeNum;
+ strncpy(e.shortName, (initials && initials[0]) ? initials : owner.short_name, sizeof(e.shortName) - 1);
+ e.shortName[sizeof(e.shortName) - 1] = '\0';
+ e.epoch = getValidTime(RTCQualityDevice, false); // 0 when no valid RTC
+
+ isNewTop = (pos == 0);
+ return pos;
+ }
+
+ void clear() override { memset(entries_, 0, sizeof(entries_)); }
+ bool loaded() const override { return loaded_; }
+
+ void load() override
+ {
+ loaded_ = true;
+ memset(entries_, 0, sizeof(entries_));
+#ifdef FSCom
+ concurrency::LockGuard g(spiLock);
+ auto f = FSCom.open(path_, FILE_O_READ);
+ if (!f)
+ return;
+ File file;
+ const bool readOk = (f.read(reinterpret_cast(&file), sizeof(file)) == sizeof(file));
+ f.close();
+ if (!readOk || file.magic != magic_ || file.version != version_) {
+ LOG_DEBUG("%s: no valid high-score file, starting fresh", logTag_);
+ return;
+ }
+ if (crc32Buffer(&file, offsetof(File, crc)) != file.crc) {
+ LOG_WARN("%s: high-score CRC mismatch, resetting table", logTag_);
+ return;
+ }
+ memcpy(entries_, file.entries, sizeof(entries_));
+ LOG_INFO("%s: loaded high scores (top=%lu)", logTag_, static_cast(entries_[0].score));
+#endif
+ }
+
+ void save() override
+ {
+#ifdef FSCom
+ {
+ concurrency::LockGuard g(spiLock);
+ FSCom.mkdir("/prefs");
+ }
+ File file;
+ memset(&file, 0, sizeof(file));
+ file.magic = magic_;
+ file.version = version_;
+ memcpy(file.entries, entries_, sizeof(entries_));
+ file.crc = crc32Buffer(&file, offsetof(File, crc));
+
+ auto sf = SafeFile(path_, true);
+ const size_t written = sf.write(reinterpret_cast(&file), sizeof(file));
+ if (!sf.close() || written != sizeof(file))
+ LOG_WARN("%s: failed to save high scores", logTag_);
+#endif
+ }
+
+ private:
+ // On-disk layout. The 8-byte header (magic + version + 3 reserved) matches both the original
+ // Snake and Tetris files byte-for-byte, so their save files still load unchanged.
+ struct File {
+ uint32_t magic;
+ uint8_t version;
+ uint8_t reserved[3];
+ Entry entries[HighScoreTableBase::HS_COUNT];
+ uint32_t crc; // crc32 over every preceding byte
+ } __attribute__((packed));
+
+ Entry entries_[HS_COUNT] = {};
+ const char *path_;
+ uint32_t magic_;
+ uint8_t version_;
+ const char *logTag_;
+ bool loaded_ = false;
+};
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/Snake.cpp b/src/modules/games/Snake.cpp
new file mode 100644
index 0000000000..13dc616f1a
--- /dev/null
+++ b/src/modules/games/Snake.cpp
@@ -0,0 +1,264 @@
+#include "Snake.h"
+
+// ===========================================================================
+// Pure SnakeGame logic (no display/FS dependencies; always compiled)
+// ===========================================================================
+
+void SnakeGame::reset(uint32_t seed)
+{
+ memset(occ, 0, sizeof(occ));
+ len = 0;
+ points = 0;
+ alive = true;
+ won = false;
+ rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
+
+ // Spawn a START_LEN snake horizontally in the middle of the board, heading right, with the
+ // head at the centre and the body trailing to its left.
+ const uint8_t cx = GRID_W / 2;
+ const uint8_t cy = GRID_H / 2;
+ dir = DIR_RIGHT;
+ pendingDir = DIR_RIGHT;
+ tailIdx = 0;
+ for (uint8_t i = 0; i < START_LEN; i++) {
+ const uint8_t x = static_cast(cx - (START_LEN - 1) + i);
+ body[i] = {x, cy};
+ setOcc(cellIndex(x, cy));
+ len++;
+ }
+ headIdx = static_cast(START_LEN - 1);
+
+ placeFood();
+}
+
+bool SnakeGame::isReverse(Direction a, Direction b)
+{
+ return (a == DIR_UP && b == DIR_DOWN) || (a == DIR_DOWN && b == DIR_UP) || (a == DIR_LEFT && b == DIR_RIGHT) ||
+ (a == DIR_RIGHT && b == DIR_LEFT);
+}
+
+bool SnakeGame::setDirection(Direction d)
+{
+ if (isReverse(dir, d))
+ return false;
+ pendingDir = d;
+ return true;
+}
+
+uint32_t SnakeGame::nextRandom()
+{
+ uint32_t x = rng;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ rng = x;
+ return x;
+}
+
+bool SnakeGame::placeFood()
+{
+ const uint16_t free = static_cast(CELL_COUNT - len);
+ if (free == 0)
+ return false; // board full -> caller treats as a win
+
+ // Pick the k-th free cell (k uniform in [0, free)) via a single linear scan. Deterministic,
+ // always valid, and cheap for a 384-cell board -- no rejection sampling / near-full special case.
+ uint16_t k = static_cast(nextRandom() % free);
+ for (uint16_t idx = 0; idx < CELL_COUNT; idx++) {
+ if (!getOcc(idx)) {
+ if (k == 0) {
+ foodCell = {static_cast(idx % GRID_W), static_cast(idx / GRID_W)};
+ return true;
+ }
+ k--;
+ }
+ }
+ return false; // unreachable while free > 0
+}
+
+bool SnakeGame::step()
+{
+ if (!alive)
+ return false;
+
+ dir = pendingDir; // commit the latched heading
+
+ const Cell h = body[headIdx];
+ int16_t nx = h.x;
+ int16_t ny = h.y;
+ switch (dir) {
+ case DIR_UP:
+ ny--;
+ break;
+ case DIR_DOWN:
+ ny++;
+ break;
+ case DIR_LEFT:
+ nx--;
+ break;
+ case DIR_RIGHT:
+ nx++;
+ break;
+ }
+
+ // Wall collision (signed math catches the 0-- underflow too).
+ if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) {
+ alive = false;
+ return false;
+ }
+
+ const uint16_t nidx = cellIndex(static_cast(nx), static_cast(ny));
+ const bool eating = (nx == foodCell.x && ny == foodCell.y);
+
+ // When not eating the tail vacates this tick, so free it first: moving into the cell the
+ // tail is leaving is legal (classic snake), moving into any other body cell is fatal.
+ if (!eating) {
+ clearOcc(cellIndex(body[tailIdx].x, body[tailIdx].y));
+ tailIdx = static_cast((tailIdx + 1) % CAP);
+ len--;
+ }
+
+ if (getOcc(nidx)) {
+ alive = false;
+ return false;
+ }
+
+ // Advance the head into the new cell.
+ headIdx = static_cast((headIdx + 1) % CAP);
+ body[headIdx] = {static_cast(nx), static_cast(ny)};
+ setOcc(nidx);
+ len++;
+
+ if (eating) {
+ points++;
+ if (!placeFood()) {
+ won = true;
+ alive = false; // board completely filled -- the player won
+ }
+ }
+
+ return alive;
+}
+
+// ===========================================================================
+// Snake adapter (display + persistence; BaseUI games build only)
+// ===========================================================================
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "graphics/Screen.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
+#include "graphics/images.h"
+#include "main.h"
+
+// --- Pixel layout on a 128x64 OLED -----------------------------------------------------------
+// Each cell is CELL_PX square. The GRID_W x GRID_H playfield (128x48) is bottom-aligned, leaving
+// a SCORE_H-pixel score bar across the top. On taller displays the board bottom-aligns and
+// centres horizontally; screen edges are the walls.
+static constexpr int16_t CELL_PX = 4;
+static constexpr int16_t SCORE_H = 16;
+
+Snake::Snake()
+{
+ scores_.load();
+}
+
+int32_t Snake::tickIntervalMs() const
+{
+ // Speed ramps up as the snake grows: 160 ms base, down to a 70 ms floor.
+ int32_t iv = 160 - static_cast(game.length()) * 3;
+ return iv < 70 ? 70 : iv;
+}
+
+void Snake::handleInput(input_broker_event ev)
+{
+ switch (ev) {
+ case INPUT_BROKER_UP:
+ game.setDirection(SnakeGame::DIR_UP);
+ break;
+ case INPUT_BROKER_DOWN:
+ game.setDirection(SnakeGame::DIR_DOWN);
+ break;
+ case INPUT_BROKER_LEFT:
+ game.setDirection(SnakeGame::DIR_LEFT);
+ break;
+ case INPUT_BROKER_RIGHT:
+ game.setDirection(SnakeGame::DIR_RIGHT);
+ break;
+ default:
+ break;
+ }
+}
+
+void Snake::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+ const int16_t w = display->getWidth();
+ const int16_t cx = x + w / 2;
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(cx, y, "S N A K E");
+ // Snake glyph, centred below the title.
+ const int16_t logoX = x + (w - snake_width) / 2;
+ const int16_t logoY = y + 15;
+ display->drawXbm(logoX, logoY, snake_width, snake_height, snake);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // On a colour display, paint the snake green with a red tongue. The forked tongue is the pair
+ // of pixels poking out past the body on the right edge (~row 6 of the 16x16 glyph); a red
+ // region registered after the green one wins where they overlap, so the body stays green.
+ const uint16_t bg = graphics::getThemeBodyBg();
+ graphics::registerTFTColorRegionDirect(logoX, logoY, snake_width, snake_height, graphics::TFTPalette::Green, bg);
+ graphics::registerTFTColorRegionDirect(logoX + 13, logoY + 5, 3, 4, graphics::TFTPalette::Red, bg);
+#endif
+ char hi[32];
+ if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
+ snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0)));
+ else
+ snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0)));
+ display->drawString(cx, y + 34, hi);
+}
+
+void Snake::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ char buf[24];
+ display->setColor(WHITE);
+ display->setFont(FONT_SMALL);
+
+ // Score bar: current score on the left, best on the right.
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ snprintf(buf, sizeof(buf), "Score %lu", static_cast(game.score()));
+ display->drawString(x + 2, y + 2, buf);
+ if (scores_.scoreAt(0) > 0) {
+ display->setTextAlignment(TEXT_ALIGN_RIGHT);
+ snprintf(buf, sizeof(buf), "Hi %lu", static_cast(scores_.scoreAt(0)));
+ display->drawString(x + display->getWidth() - 2, y + 2, buf);
+ }
+ display->drawLine(x, y + SCORE_H - 1, x + display->getWidth() - 1, y + SCORE_H - 1);
+
+ // Board is bottom-aligned; centre it horizontally.
+ const int16_t boardW = SnakeGame::GRID_W * CELL_PX;
+ const int16_t boardH = SnakeGame::GRID_H * CELL_PX;
+ const int16_t ox = x + (display->getWidth() - boardW) / 2;
+ const int16_t oy = y + display->getHeight() - boardH;
+
+ for (uint16_t i = 0; i < game.length(); i++) {
+ SnakeGame::Cell c = game.bodyAt(i);
+ display->fillRect(ox + c.x * CELL_PX, oy + c.y * CELL_PX, CELL_PX, CELL_PX);
+ }
+ // Food: a 2x2 dot centred in its cell.
+ SnakeGame::Cell f = game.food();
+ display->fillRect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2);
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // On a colour display, paint the whole snake green, then the apple red on top. One region over
+ // the board tints every lit body cell green (the snake can be too long for per-cell regions);
+ // a red region over the food pixel wins where it overlaps.
+ const uint16_t bg = graphics::getThemeBodyBg();
+ graphics::registerTFTColorRegionDirect(ox, oy, boardW, boardH, graphics::TFTPalette::MeshtasticGreen, bg);
+ graphics::registerTFTColorRegionDirect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2, graphics::TFTPalette::Red, bg);
+#endif
+}
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/Snake.h b/src/modules/games/Snake.h
new file mode 100644
index 0000000000..293707ff4e
--- /dev/null
+++ b/src/modules/games/Snake.h
@@ -0,0 +1,152 @@
+#pragma once
+
+#include
+#include
+#include
+
+/**
+ * Pure, self-contained Snake game logic.
+ *
+ * Deliberately free of Arduino / Screen / heap dependencies so it can be unit-tested natively
+ * (see test/test_snake) and reused by the Snake adapter below without pulling in the display stack.
+ *
+ * The board is a fixed GRID_W x GRID_H grid of cells. The snake body lives in a ring buffer
+ * sized to the whole board, plus an occupancy bitmap for O(1) collision and food-placement
+ * checks. No dynamic allocation: total state is ~1 KB and statically sized.
+ */
+class SnakeGame
+{
+ public:
+ // Playfield dimensions in cells. Chosen so that at CELL_PX = 4 the board is 128x48, leaving
+ // a 16 px score bar at the top of a 128x64 OLED (see Snake.cpp for the pixel layout).
+ static constexpr uint8_t GRID_W = 32;
+ static constexpr uint8_t GRID_H = 12;
+ static constexpr uint16_t CELL_COUNT = static_cast(GRID_W) * GRID_H; // 384
+
+ // Initial snake length at the start of a game.
+ static constexpr uint8_t START_LEN = 3;
+
+ enum Direction : uint8_t { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT };
+
+ struct Cell {
+ uint8_t x;
+ uint8_t y;
+ };
+
+ /**
+ * (Re)start a game. The snake spawns horizontally in the middle of the board heading right,
+ * and the first food is placed. `seed` drives deterministic food placement (xorshift32).
+ */
+ void reset(uint32_t seed);
+
+ /**
+ * Latch a new heading to be applied on the next step(). A 180-degree reversal of the
+ * currently-committed direction is rejected (returns false) because it would immediately
+ * run the head into the neck. Comparing against the committed direction (not the pending
+ * one) means multiple key presses within a single tick can't chain into a reversal.
+ */
+ bool setDirection(Direction d);
+
+ /**
+ * Advance the simulation by one tick. Returns true if the snake is still alive afterwards,
+ * false if this move ended the game (wall hit, self-collision, or board filled == win).
+ * Once dead, further step() calls are no-ops returning false.
+ */
+ bool step();
+
+ bool isPlaying() const { return alive; }
+ bool isWon() const { return won; }
+ uint16_t length() const { return len; }
+ uint32_t score() const { return points; }
+
+ Cell head() const { return body[headIdx]; }
+ Cell food() const { return foodCell; }
+ Direction direction() const { return dir; }
+
+ /// True if cell (x,y) is currently part of the snake body.
+ bool occupied(uint8_t x, uint8_t y) const { return getOcc(cellIndex(x, y)); }
+
+ /// Iterate the body from tail (i == 0) to head (i == length()-1); used by the renderer.
+ Cell bodyAt(uint16_t i) const { return body[(tailIdx + i) % CAP]; }
+
+ /**
+ * Test/aid seam: force the next food to a specific cell so unit tests can drive
+ * deterministic growth. Unused in production. Caller must pass an unoccupied cell.
+ */
+ void placeFoodAt(uint8_t x, uint8_t y) { foodCell = {x, y}; }
+
+ private:
+ static constexpr uint16_t CAP = CELL_COUNT; // ring capacity == board size (len is tracked explicitly)
+
+ Cell body[CAP] = {0};
+ uint16_t headIdx = 0; // ring index of the head (front) cell
+ uint16_t tailIdx = 0; // ring index of the tail (oldest) cell
+ uint16_t len = 0; // number of live body cells
+
+ uint8_t occ[(CELL_COUNT + 7) / 8] = {0}; // occupancy bitmap, indexed by cellIndex()
+
+ Cell foodCell = {0, 0};
+ Direction dir = DIR_RIGHT;
+ Direction pendingDir = DIR_RIGHT;
+ uint32_t points = 0;
+ uint32_t rng = 1; // xorshift32 state (never 0)
+ bool alive = false;
+ bool won = false;
+
+ static uint16_t cellIndex(uint8_t x, uint8_t y) { return static_cast(y) * GRID_W + x; }
+ bool getOcc(uint16_t idx) const { return (occ[idx >> 3] >> (idx & 7)) & 1u; }
+ void setOcc(uint16_t idx) { occ[idx >> 3] |= static_cast(1u << (idx & 7)); }
+ void clearOcc(uint16_t idx) { occ[idx >> 3] &= static_cast(~(1u << (idx & 7))); }
+
+ uint32_t nextRandom();
+ bool placeFood(); // returns false if the board is full (no free cell -> win)
+ static bool isReverse(Direction a, Direction b);
+};
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "Game.h"
+#include "HighScoreTable.h"
+
+/**
+ * Snake as a hosted Game. Wraps the pure SnakeGame logic above and supplies the attract art, the
+ * playfield renderer, the direction input, the length-based speed curve, and its own high-score
+ * table. The new-high-score mesh announcement is shared by all games and lives in GamesModule.
+ */
+class Snake : public Game
+{
+ public:
+ Snake();
+
+ const char *name() const override { return "Snake"; }
+
+ void start(uint32_t seed) override { game.reset(seed); }
+ bool tick() override { return game.step(); }
+ bool isPlaying() const override { return game.isPlaying(); }
+ uint32_t score() const override { return game.score(); }
+ int32_t tickIntervalMs() const override;
+
+ void handleInput(input_broker_event ev) override;
+
+ void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
+ void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
+
+ HighScoreTableBase &scores() override { return scores_; }
+
+ private:
+ // On-disk high-score record; layout preserved from the original SnakeModule so snake.dat keeps
+ // loading. Magic 'SNEK', file version 1.
+ struct SnakeEntry {
+ uint32_t score;
+ uint32_t nodeNum;
+ char shortName[5]; // three characters, NUL-terminated
+ uint32_t epoch; // getValidTime(), 0 if no RTC
+ } __attribute__((packed));
+
+ SnakeGame game;
+ HighScoreTable scores_{"/prefs/snake.dat", 0x534E454Bu, 1, "Snake"};
+};
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/Tetris.cpp b/src/modules/games/Tetris.cpp
new file mode 100644
index 0000000000..7c24b32699
--- /dev/null
+++ b/src/modules/games/Tetris.cpp
@@ -0,0 +1,494 @@
+#include "Tetris.h"
+
+// ===========================================================================
+// Pure TetrisGame logic (no display/FS dependencies; always compiled)
+// ===========================================================================
+
+// ---------------------------------------------------------------------------
+// Shape table
+//
+// SHAPES[type][rot][row] encodes each row of the 4×4 bounding box as a 4-bit
+// column bitmask (bit 0 = leftmost column). All seven SRS tetrominoes in their
+// four CW rotations.
+//
+// Type 0 I Type 1 O Type 2 T Type 3 S
+// Type 4 Z Type 5 J Type 6 L
+// ---------------------------------------------------------------------------
+const uint8_t TetrisGame::SHAPES[PIECE_TYPES][4][4] = {
+ // I ---- rot0: .XXXX rot1: ..X.. rot2: ..... rot3: .X...
+ {{0, 15, 0, 0}, {4, 4, 4, 4}, {0, 0, 15, 0}, {2, 2, 2, 2}},
+ // O ---- same all rotations
+ {{6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}},
+ // T ----
+ {{2, 7, 0, 0}, {2, 6, 2, 0}, {0, 7, 2, 0}, {2, 3, 2, 0}},
+ // S ----
+ {{6, 3, 0, 0}, {1, 3, 2, 0}, {0, 6, 3, 0}, {2, 6, 4, 0}},
+ // Z ----
+ {{3, 6, 0, 0}, {2, 3, 1, 0}, {0, 3, 6, 0}, {4, 6, 2, 0}},
+ // J ----
+ {{1, 7, 0, 0}, {6, 2, 2, 0}, {0, 7, 4, 0}, {2, 2, 3, 0}},
+ // L ----
+ {{4, 7, 0, 0}, {2, 2, 6, 0}, {0, 7, 1, 0}, {3, 2, 2, 0}},
+};
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+bool TetrisGame::pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc)
+{
+ if (type >= PIECE_TYPES || rot >= 4 || pr >= 4 || pc >= 4)
+ return false;
+ return (SHAPES[type][rot][pr] >> pc) & 1u;
+}
+
+uint32_t TetrisGame::nextRandom()
+{
+ uint32_t x = rng;
+ x ^= x << 13;
+ x ^= x >> 17;
+ x ^= x << 5;
+ rng = x;
+ return x;
+}
+
+TetrisGame::Piece TetrisGame::spawnPiece(uint8_t type) const
+{
+ // Centre horizontally in the 10-wide board; bounding box starts at col 3.
+ Piece p;
+ p.type = type;
+ p.rot = 0;
+ p.col = static_cast((BOARD_COLS - 4) / 2); // 3 for a 10-wide board
+ p.row = 0;
+ return p;
+}
+
+bool TetrisGame::canPlace(const Piece &p) const
+{
+ for (uint8_t pr = 0; pr < 4; pr++) {
+ for (uint8_t pc = 0; pc < 4; pc++) {
+ if (!pieceCell(p.type, p.rot, pr, pc))
+ continue;
+ const int16_t br = static_cast(p.row) + pr;
+ const int16_t bc = static_cast(p.col) + pc;
+ if (br < 0)
+ continue; // above the board - allowed during spawn
+ if (br >= BOARD_ROWS || bc < 0 || bc >= BOARD_COLS)
+ return false;
+ if (board[br][bc] != 0)
+ return false;
+ }
+ }
+ return true;
+}
+
+void TetrisGame::lockPiece()
+{
+ for (uint8_t pr = 0; pr < 4; pr++) {
+ for (uint8_t pc = 0; pc < 4; pc++) {
+ if (!pieceCell(cur.type, cur.rot, pr, pc))
+ continue;
+ const int16_t br = static_cast(cur.row) + pr;
+ const int16_t bc = static_cast(cur.col) + pc;
+ if (br >= 0 && br < BOARD_ROWS && bc >= 0 && bc < BOARD_COLS)
+ board[br][bc] = static_cast(cur.type + 1); // colour 1..7
+ }
+ }
+}
+
+int TetrisGame::clearLines()
+{
+ int cleared = 0;
+ for (int r = BOARD_ROWS - 1; r >= 0;) {
+ bool full = true;
+ for (int c = 0; c < BOARD_COLS && full; c++) {
+ if (board[r][c] == 0)
+ full = false;
+ }
+ if (full) {
+ // Shift every row above down by one.
+ for (int rr = r; rr > 0; rr--)
+ memcpy(board[rr], board[rr - 1], BOARD_COLS);
+ memset(board[0], 0, BOARD_COLS);
+ cleared++;
+ // Recheck same index - it now contains the row that was above.
+ } else {
+ r--;
+ }
+ }
+ return cleared;
+}
+
+void TetrisGame::advanceNext()
+{
+ lockPiece();
+
+ const int cleared = clearLines();
+ if (cleared > 0) {
+ lines += static_cast(cleared);
+ // Nintendo-style line-clear scoring (×level).
+ static const uint16_t LINE_PTS[5] = {0, 100, 300, 500, 800};
+ pts += LINE_PTS[cleared < 5 ? cleared : 4] * lvl;
+ // Level up every 10 lines, cap at 20.
+ const uint8_t newLvl = static_cast(lines / 10 + 1);
+ lvl = newLvl > 20 ? 20 : newLvl;
+ }
+
+ // nxt becomes the active piece; generate a fresh nxt.
+ cur = nxt;
+ if (!canPlace(cur)) {
+ alive = false;
+ return;
+ }
+ nxt = spawnPiece(static_cast(nextRandom() % PIECE_TYPES));
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+void TetrisGame::reset(uint32_t seed)
+{
+ memset(board, 0, sizeof(board));
+ pts = 0;
+ lvl = 1;
+ lines = 0;
+ alive = true;
+ rng = seed ? seed : 0xA5A5A5A5u;
+ cur = spawnPiece(static_cast(nextRandom() % PIECE_TYPES));
+ nxt = spawnPiece(static_cast(nextRandom() % PIECE_TYPES));
+}
+
+bool TetrisGame::moveLeft()
+{
+ Piece p = cur;
+ p.col--;
+ if (!canPlace(p))
+ return false;
+ cur = p;
+ return true;
+}
+
+bool TetrisGame::moveRight()
+{
+ Piece p = cur;
+ p.col++;
+ if (!canPlace(p))
+ return false;
+ cur = p;
+ return true;
+}
+
+bool TetrisGame::rotate()
+{
+ Piece p = cur;
+ p.rot = static_cast((p.rot + 1) % 4);
+ if (canPlace(p)) {
+ cur = p;
+ return true;
+ }
+ // Wall-kick: try ±1, ±2 column offsets.
+ const int8_t kicks[] = {-1, 1, -2, 2};
+ for (int8_t kick : kicks) {
+ Piece q = p;
+ q.col = static_cast(p.col + kick);
+ if (canPlace(q)) {
+ cur = q;
+ return true;
+ }
+ }
+ return false;
+}
+
+bool TetrisGame::softDrop()
+{
+ Piece p = cur;
+ p.row++;
+ if (!canPlace(p)) {
+ advanceNext(); // locks cur, clears lines, spawns next; may set alive=false
+ return false;
+ }
+ cur = p;
+ return true;
+}
+
+void TetrisGame::hardDrop()
+{
+ const int8_t land = ghostRow();
+ const uint32_t dropped = static_cast(land - cur.row);
+ pts += dropped * 2;
+ cur.row = land;
+ advanceNext();
+}
+
+int8_t TetrisGame::ghostRow() const
+{
+ Piece p = cur;
+ while (true) {
+ Piece q = p;
+ q.row++;
+ if (!canPlace(q))
+ break;
+ p = q;
+ }
+ return p.row;
+}
+
+bool TetrisGame::step()
+{
+ if (!alive)
+ return false;
+ softDrop(); // may lock and advance, potentially setting alive=false
+ return alive;
+}
+
+// ===========================================================================
+// Tetris adapter (display + persistence + mesh; BaseUI games build only)
+// ===========================================================================
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "GamesModule.h"
+#include "NodeDB.h"
+#include "graphics/Screen.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
+#include "graphics/images.h"
+#include "main.h"
+#include
+#include
+
+// ---------------------------------------------------------------------------
+// Vertical pixel layout on a 128×64 OLED
+//
+// Board occupies the left side of the screen:
+// x = col × CELL_PX (col 0 at left edge)
+// y = row × CELL_PX (row 0 at top edge)
+// 10 cols × 4 px = 40 px wide
+// 16 rows × 4 px = 64 px tall (fills the full display height)
+//
+// Score panel: x = SCORE_OX .. 127 (86 px wide)
+// Labels (SCR / LVL / NXT) + values + next-piece preview.
+// ---------------------------------------------------------------------------
+static constexpr int16_t CELL_PX = 4;
+
+Tetris::Tetris()
+{
+ scores_.load();
+}
+
+int32_t Tetris::tickIntervalMs() const
+{
+ // Speed ramps with level: 600 ms base, 45 ms per level, floor 50 ms.
+ int32_t iv = 600 - static_cast(game.level()) * 45;
+ return iv < 50 ? 50 : iv;
+}
+
+void Tetris::handleInput(input_broker_event ev)
+{
+ switch (ev) {
+ case INPUT_BROKER_UP:
+ game.rotate();
+ break;
+ case INPUT_BROKER_LEFT:
+ game.moveLeft();
+ break;
+ case INPUT_BROKER_RIGHT:
+ game.moveRight();
+ break;
+ case INPUT_BROKER_DOWN:
+ game.softDrop();
+ break;
+ case INPUT_BROKER_SELECT:
+ case INPUT_BROKER_SELECT_LONG:
+ game.hardDrop();
+ break;
+ default:
+ break;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+// Classic tetromino colours, indexed by piece type (0..6 == I O T S Z J L). Native RGB565.
+static uint16_t tetrominoColor(uint8_t type)
+{
+ using namespace graphics;
+ switch (type) {
+ case 0:
+ return TFTPalette::Cyan; // I
+ case 1:
+ return TFTPalette::Yellow; // O
+ case 2:
+ return TFTPalette::Magenta; // T
+ case 3:
+ return TFTPalette::Green; // S
+ case 4:
+ return TFTPalette::Red; // Z
+ case 5:
+ return TFTPalette::Blue; // J
+ case 6:
+ return TFTPalette::Orange; // L
+ default:
+ return TFTPalette::White;
+ }
+}
+#endif
+
+void Tetris::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ display->setColor(WHITE);
+ const int16_t w = display->getWidth();
+ const int16_t cx = x + w / 2;
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(cx, y, "T E T R I S");
+ const int16_t logoX = x + (w - tetris_width) / 2;
+ const int16_t logoY = y + 15;
+ display->drawXbm(logoX, logoY, tetris_width, tetris_height, tetris);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // The logo glyph is a T tetromino -- tint it the T-piece colour on colour displays.
+ graphics::registerTFTColorRegionDirect(logoX, logoY, tetris_width, tetris_height, tetrominoColor(2),
+ graphics::getThemeBodyBg());
+#endif
+ char hi[32];
+ if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
+ snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0)));
+ else
+ snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0)));
+ display->drawString(cx, y + 34, hi);
+}
+
+void Tetris::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
+{
+ // Centered vertical layout:
+ // board: 10 cols × CELL_PX wide, fills display height (BOARD_ROWS × CELL_PX)
+ // left panel (NXT preview) : x = 0 .. ox-2
+ // right panel (SCR / LVL) : x = ox+boardW+1 .. display.width-1
+ const int16_t boardW = TetrisGame::BOARD_COLS * CELL_PX; // 40
+ const int16_t ox = x + (display->getWidth() - boardW) / 2; // horizontal centre
+ const int16_t oy = y;
+
+ display->setColor(WHITE);
+
+ // Separator lines either side of the board, plus bottom wall.
+ display->drawLine(ox - 1, oy, ox - 1, oy + display->getHeight() - 1);
+ display->drawLine(ox + boardW, oy, ox + boardW, oy + display->getHeight() - 1);
+ display->drawLine(ox - 1, oy + display->getHeight() - 1, ox + boardW, oy + display->getHeight() - 1);
+
+ // Cell helper.
+ auto drawCell = [&](int8_t col, int8_t row) {
+ if (col < 0 || row < 0 || col >= TetrisGame::BOARD_COLS || row >= TetrisGame::BOARD_ROWS)
+ return;
+ display->fillRect(ox + static_cast(col) * CELL_PX, oy + static_cast(row) * CELL_PX, CELL_PX - 1,
+ CELL_PX - 1);
+ };
+
+ // Locked cells.
+ for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++)
+ for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++)
+ if (game.board[r][c])
+ drawCell(static_cast(c), static_cast(r));
+
+ // Ghost piece - hollow outline.
+ const TetrisGame::Piece &cur = game.current();
+ const int8_t ghostR = game.ghostRow();
+ if (ghostR != cur.row) {
+ for (uint8_t pr = 0; pr < 4; pr++) {
+ for (uint8_t pc = 0; pc < 4; pc++) {
+ if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
+ continue;
+ const int8_t gc = static_cast(cur.col + pc);
+ const int8_t gr = static_cast(ghostR + pr);
+ if (gc < 0 || gr < 0 || gc >= TetrisGame::BOARD_COLS || gr >= TetrisGame::BOARD_ROWS)
+ continue;
+ display->setPixel(ox + static_cast(gc) * CELL_PX + 1, oy + static_cast(gr) * CELL_PX + 1);
+ }
+ }
+ }
+
+ // Active piece - filled.
+ for (uint8_t pr = 0; pr < 4; pr++) {
+ for (uint8_t pc = 0; pc < 4; pc++) {
+ if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
+ continue;
+ drawCell(static_cast(cur.col + pc), static_cast(cur.row + pr));
+ }
+ }
+
+ // --- Right panel: SCR and LVL ---
+ const int16_t rpx = ox + boardW + 2;
+ char buf[12];
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->drawString(rpx, y + 2, "SCR");
+ snprintf(buf, sizeof(buf), "%lu", static_cast(game.score()));
+ display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL, buf);
+ display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 2 + 2, "LVL");
+ snprintf(buf, sizeof(buf), "%u", static_cast(game.level()));
+ display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 3 + 2, buf);
+
+ // --- Left panel: NXT (next piece preview) centred in the panel ---
+ const int16_t lpanelW = ox - 2; // pixels available left of board separator
+ static constexpr int16_t PREV_PX = 3; // px per preview cell
+ const int16_t previewW = 4 * PREV_PX; // 12 px
+ const int16_t lpx = x + (lpanelW - previewW) / 2;
+ const int16_t nxtLabelY = y + 2;
+ const int16_t nxtPreviewY = nxtLabelY + FONT_HEIGHT_SMALL + 2;
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(x + lpanelW / 2, nxtLabelY, "NXT");
+ const TetrisGame::Piece &nxt = game.next();
+ for (uint8_t pr = 0; pr < 4; pr++)
+ for (uint8_t pc = 0; pc < 4; pc++)
+ if (TetrisGame::pieceCell(nxt.type, nxt.rot, pr, pc))
+ display->fillRect(lpx + static_cast(pc) * PREV_PX, nxtPreviewY + static_cast(pr) * PREV_PX,
+ PREV_PX - 1, PREV_PX - 1);
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // On a colour display (e.g. HUB75), tint every block with its tetromino colour. The mono buffer
+ // still carries the block pixels drawn above; registering a colour region over each run of
+ // same-colour cells makes those "on" pixels render in colour instead of the theme foreground.
+ // Runs are merged horizontally per row to stay within the region budget, and empty cells cost
+ // nothing, so the region count only grows with how full the board is.
+ const uint16_t bg = graphics::getThemeBodyBg();
+
+ // Combined colour grid: locked cells plus the falling piece (same colour source == type + 1).
+ uint8_t cg[TetrisGame::BOARD_ROWS][TetrisGame::BOARD_COLS];
+ for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++)
+ for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++)
+ cg[r][c] = game.board[r][c];
+ for (uint8_t pr = 0; pr < 4; pr++)
+ for (uint8_t pc = 0; pc < 4; pc++) {
+ if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
+ continue;
+ const int br = cur.row + pr, bc = cur.col + pc;
+ if (br >= 0 && br < TetrisGame::BOARD_ROWS && bc >= 0 && bc < TetrisGame::BOARD_COLS)
+ cg[br][bc] = static_cast(cur.type + 1);
+ }
+ for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) {
+ uint8_t c = 0;
+ while (c < TetrisGame::BOARD_COLS) {
+ const uint8_t v = cg[r][c];
+ if (v == 0) {
+ c++;
+ continue;
+ }
+ const uint8_t c0 = c;
+ while (c < TetrisGame::BOARD_COLS && cg[r][c] == v)
+ c++;
+ const int16_t rx = ox + static_cast(c0) * CELL_PX;
+ const int16_t ry = oy + static_cast(r) * CELL_PX;
+ const int16_t rw = static_cast(c - c0) * CELL_PX - 1; // span the run, drop the trailing gap
+ graphics::registerTFTColorRegionDirect(rx, ry, rw, CELL_PX - 1, tetrominoColor(static_cast(v - 1)), bg);
+ }
+ }
+ // Next-piece preview: one region over the 4x4 grid tinted with its colour.
+ graphics::registerTFTColorRegionDirect(lpx, nxtPreviewY, 4 * PREV_PX, 4 * PREV_PX, tetrominoColor(nxt.type), bg);
+#endif
+}
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/modules/games/Tetris.h b/src/modules/games/Tetris.h
new file mode 100644
index 0000000000..4b429ec2ed
--- /dev/null
+++ b/src/modules/games/Tetris.h
@@ -0,0 +1,156 @@
+#pragma once
+
+#include
+#include
+
+/**
+ * Pure, self-contained Tetris game logic.
+ *
+ * No Arduino/display dependencies - designed to be unit-tested natively and reused by the Tetris
+ * adapter below without pulling in the display stack.
+ *
+ * Board coordinate system: col=0 is leftmost, row=0 is top (gravity goes toward higher rows).
+ * board[row][col] holds 0 (empty) or 1..7 (locked piece colour index).
+ * No dynamic allocation: total struct size is ~260 bytes.
+ */
+class TetrisGame
+{
+ public:
+ static constexpr uint8_t BOARD_COLS = 10;
+ static constexpr uint8_t BOARD_ROWS = 16; // 16×4 px = 64 px - fills a standard 64px OLED
+ static constexpr uint8_t PIECE_TYPES = 7; // I O T S Z J L
+
+ struct Piece {
+ int8_t col; // left column of the 4×4 bounding box (may be negative during spawn)
+ int8_t row; // top row of the 4×4 bounding box (may be negative during spawn)
+ uint8_t type; // 0..6
+ uint8_t rot; // 0..3
+ };
+
+ // board[row][col]: 0 = empty, 1..7 = locked piece colour
+ uint8_t board[BOARD_ROWS][BOARD_COLS];
+
+ /** Start (or restart) the game. seed drives the xorshift32 RNG. */
+ void reset(uint32_t seed);
+
+ /** Shift the current piece left one column. Returns true if it moved. */
+ bool moveLeft();
+
+ /** Shift the current piece right one column. Returns true if it moved. */
+ bool moveRight();
+
+ /**
+ * Rotate the current piece CW. Tries a basic wall-kick (±1, ±2 column) if the
+ * natural rotation overlaps a wall or locked cell. Returns true if it rotated.
+ */
+ bool rotate();
+
+ /**
+ * Move the current piece down one row. If it cannot fall it is locked,
+ * lines are cleared, the next piece becomes current, and a new next is generated.
+ * Returns false after locking (game may or may not be over).
+ */
+ bool softDrop();
+
+ /**
+ * Instantly drop the current piece to where it would land and lock it.
+ * Awards 2 pts per row dropped.
+ */
+ void hardDrop();
+
+ /**
+ * Gravity tick: same as softDrop() - move down one row, lock if needed.
+ * Returns true while the game is alive, false after game-over.
+ */
+ bool step();
+
+ bool isPlaying() const { return alive; }
+ uint32_t score() const { return pts; }
+ uint8_t level() const { return lvl; }
+ uint16_t linesCleared() const { return lines; }
+
+ const Piece ¤t() const { return cur; }
+ const Piece &next() const { return nxt; }
+
+ /**
+ * Returns the top row the current piece would occupy if instantly dropped.
+ * Used by the renderer to show a ghost/shadow piece.
+ */
+ int8_t ghostRow() const;
+
+ /**
+ * Returns true if cell (pr, pc) within the 4×4 bounding box is filled for
+ * the given piece type and rotation. Safe for any (type, rot, pr, pc).
+ */
+ static bool pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc);
+
+ private:
+ Piece cur = {};
+ Piece nxt = {};
+ uint32_t pts = 0;
+ uint8_t lvl = 1;
+ uint16_t lines = 0;
+ uint32_t rng = 1; // xorshift32 state - must never be 0
+ bool alive = false;
+
+ uint32_t nextRandom();
+ Piece spawnPiece(uint8_t type) const;
+ void advanceNext(); // lock cur, clear lines, shift nxt→cur, spawn new nxt
+ bool canPlace(const Piece &p) const;
+ void lockPiece();
+ int clearLines(); // returns number of lines cleared (0..4)
+
+ // Shape table: SHAPES[type][rot][row] = 4-bit column bitmask.
+ // Bit 0 = leftmost column (col 0), bit 3 = rightmost (col 3) of the 4×4 box.
+ static const uint8_t SHAPES[PIECE_TYPES][4][4];
+};
+
+#include "configuration.h"
+
+#if HAS_SCREEN && BASEUI_HAS_GAMES
+
+#include "Game.h"
+#include "HighScoreTable.h"
+
+/**
+ * Tetris as a hosted Game. Wraps the pure TetrisGame logic above and supplies the attract art, the
+ * portrait playfield renderer, the rotate/move/drop input, the level-based speed curve, and its
+ * own high-score table. The new-high-score mesh announcement is shared by all games and lives in
+ * GamesModule.
+ */
+class Tetris : public Game
+{
+ public:
+ Tetris();
+
+ const char *name() const override { return "Tetris"; }
+
+ void start(uint32_t seed) override { game.reset(seed); }
+ bool tick() override { return game.step(); }
+ bool isPlaying() const override { return game.isPlaying(); }
+ uint32_t score() const override { return game.score(); }
+ int32_t tickIntervalMs() const override;
+
+ void handleInput(input_broker_event ev) override;
+
+ void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
+ void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
+ const char *gameOverHint() const override { return "SEL: scores BCK: exit"; }
+
+ HighScoreTableBase &scores() override { return scores_; }
+
+ private:
+ // On-disk high-score record; layout preserved from the original TetrisModule so tetris.dat
+ // keeps loading. Magic 'TETR', file version 1.
+ struct TetrisEntry {
+ uint32_t score;
+ char shortName[5]; // NUL-terminated 3-char display name
+ uint32_t nodeNum;
+ uint32_t epoch;
+ } __attribute__((packed));
+
+ TetrisGame game;
+ HighScoreTable scores_{"/prefs/tetris.dat", 0x54455452u, 1, "Tetris"};
+};
+
+#endif // HAS_SCREEN && BASEUI_HAS_GAMES
diff --git a/src/platform/portduino/HUB75Native.cpp b/src/platform/portduino/HUB75Native.cpp
new file mode 100644
index 0000000000..55f0c1415a
--- /dev/null
+++ b/src/platform/portduino/HUB75Native.cpp
@@ -0,0 +1,163 @@
+#include "configuration.h"
+
+#if defined(HAS_HUB75_NATIVE)
+
+#include "HUB75Native.h"
+#include "PortduinoGlue.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
+#include
+
+HUB75Native::HUB75Native(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C)
+{
+ // The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306 page layout). The
+ // panel size comes from config.yaml at runtime (cols*chain x rows*parallel, computed in
+ // loadConfig()). Config is fully loaded by portduinoSetup() before Screen constructs.
+ setGeometry(GEOMETRY_RAWMODE, portduino_config.displayWidth, portduino_config.displayHeight);
+ LOG_DEBUG("HUB75Native %dx%d", portduino_config.displayWidth, portduino_config.displayHeight);
+}
+
+HUB75Native::~HUB75Native()
+{
+ if (matrix) {
+ delete matrix;
+ matrix = nullptr;
+ }
+}
+
+// Bring up the rpi-rgb-led-matrix driver from config.yaml. The library owns the GPIO pins
+// (selected by HardwareMapping) and runs its own refresh thread, so there is no DMA/I2S setup.
+bool HUB75Native::connect()
+{
+ LOG_INFO("Do HUB75 init");
+
+ rgb_matrix::RGBMatrix::Options options;
+ options.hardware_mapping = portduino_config.hub75_hardware_mapping.c_str();
+ options.rows = portduino_config.hub75_rows;
+ options.cols = portduino_config.hub75_cols;
+ options.chain_length = portduino_config.hub75_chain_length;
+ options.parallel = portduino_config.hub75_parallel;
+ options.pwm_bits = portduino_config.hub75_pwm_bits;
+ options.pwm_lsb_nanoseconds = portduino_config.hub75_pwm_lsb_nanoseconds;
+ options.brightness = portduino_config.hub75_brightness;
+ options.scan_mode = portduino_config.hub75_scan_mode;
+ options.row_address_type = portduino_config.hub75_row_address_type;
+ options.multiplexing = portduino_config.hub75_multiplexing;
+ options.disable_hardware_pulsing = portduino_config.hub75_disable_hardware_pulsing;
+ options.show_refresh_rate = portduino_config.hub75_show_refresh_rate;
+ options.inverse_colors = portduino_config.hub75_inverse_colors;
+ // RGB order is handled by the library instead of a software swap (see display()).
+ options.led_rgb_sequence = portduino_config.hub75_led_rgb_sequence.c_str();
+ options.limit_refresh_rate_hz = portduino_config.hub75_limit_refresh_rate_hz;
+ if (!portduino_config.hub75_pixel_mapper_config.empty())
+ options.pixel_mapper_config = portduino_config.hub75_pixel_mapper_config.c_str();
+ if (!portduino_config.hub75_panel_type.empty())
+ options.panel_type = portduino_config.hub75_panel_type.c_str();
+
+ rgb_matrix::RuntimeOptions runtime;
+ runtime.gpio_slowdown = portduino_config.hub75_gpio_slowdown;
+ runtime.daemon = 0; // run in-process; the library starts its own refresh thread
+ runtime.drop_privileges = 0; // meshtasticd manages its own privileges (keeps GPIO/LoRa access)
+
+ matrix = rgb_matrix::CreateMatrixFromOptions(options, runtime);
+ if (matrix == nullptr) {
+ LOG_ERROR("HUB75 CreateMatrixFromOptions() failed (need root / /dev/gpiomem on a Pi?)");
+ return false;
+ }
+ brightness = portduino_config.hub75_brightness; // library scale is 1..100
+ matrix->SetBrightness(brightness); // applies to the matrix and all its FrameCanvases
+ matrix->Clear();
+ // Offscreen buffer for double-buffered, tear-free presentation (see display()).
+ offscreen = matrix->CreateFrameCanvas();
+ return true;
+}
+
+void HUB75Native::display()
+{
+ if (!matrix || !offscreen)
+ return;
+
+ const uint16_t onNative = graphics::TFTPalette::White;
+ const uint16_t offNative = graphics::getThemeBodyBg();
+
+ const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8));
+ const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8));
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
+#endif
+
+ // Full-frame redraw into the offscreen canvas every call (no dirty-diff needed at these
+ // resolutions), then SwapOnVSync() below presents it atomically for tear-free output.
+ for (uint16_t y = 0; y < displayHeight; y++) {
+ const uint32_t yByteIndex = (y / 8) * displayWidth;
+ const uint8_t yByteMask = (uint8_t)(1 << (y & 7));
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (hasColorRegions)
+ graphics::beginTFTColorRow((int16_t)y);
+#endif
+
+ for (uint16_t x = 0; x < displayWidth; x++) {
+ const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0;
+
+ uint16_t be;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (hasColorRegions)
+ be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe);
+ else
+ be = isset ? onBe : offBe;
+#else
+ be = isset ? onBe : offBe;
+#endif
+
+ const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565
+ const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3);
+ const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2);
+ const uint8_t b = (uint8_t)((c & 0x1F) << 3);
+ offscreen->SetPixel(x, y, r, g, b);
+ }
+ }
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Regions are re-registered every frame by the renderers; clear so they don't accumulate.
+ graphics::clearTFTColorRegions();
+#endif
+
+ // Present the finished frame at the next vsync; the returned canvas (the previously shown one)
+ // becomes our next offscreen buffer.
+ offscreen = matrix->SwapOnVSync(offscreen);
+}
+
+void HUB75Native::sendCommand(uint8_t com)
+{
+ if (!matrix || !offscreen)
+ return;
+
+ switch (com) {
+ case DISPLAYON:
+ matrix->SetBrightness(brightness);
+ break;
+ case DISPLAYOFF:
+ // rpi-rgb-led-matrix clamps brightness 0 up to 1 and its refresh thread keeps scanning the
+ // presented canvas, so SetBrightness(0) alone leaves the last frame faintly lit. Present a
+ // cleared frame so the panel actually goes black while asleep. (matrix->Clear() would only
+ // clear the RGBMatrix's own buffer, not the FrameCanvas we swap in.) Wake repaints via the
+ // next display().
+ offscreen->Clear();
+ offscreen = matrix->SwapOnVSync(offscreen);
+ break;
+ default:
+ // Drop all other SSD1306 init/config commands - not meaningful for the matrix.
+ break;
+ }
+}
+
+void HUB75Native::setDisplayBrightness(uint8_t _brightness)
+{
+ brightness = _brightness;
+ if (matrix)
+ matrix->SetBrightness(brightness);
+}
+
+#endif // HAS_HUB75_NATIVE
diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp
index 3903da2e87..8da3d085b9 100644
--- a/src/platform/portduino/PortduinoGlue.cpp
+++ b/src/platform/portduino/PortduinoGlue.cpp
@@ -1017,6 +1017,39 @@ bool loadConfig(const char *configPath)
}
}
}
+#if !defined(HAS_HUB75_NATIVE)
+ if (portduino_config.displayPanel == hub75) {
+ std::cerr << "HUB75 display panel selected, but this build does not support HUB75" << std::endl;
+ exit(EXIT_FAILURE);
+ }
+#endif
+ // HUB75 RGB matrix (Raspberry Pi). Options map onto rgb_matrix::RGBMatrix::Options +
+ // RuntimeOptions; the library owns its GPIO pins so nothing is read via readGPIOFromYaml.
+ if (portduino_config.displayPanel == hub75 && yamlConfig["Display"]["HUB75"]) {
+ YAML::Node hub75 = yamlConfig["Display"]["HUB75"];
+ portduino_config.hub75_hardware_mapping = hub75["HardwareMapping"].as("regular");
+ portduino_config.hub75_rows = hub75["Rows"].as(64);
+ portduino_config.hub75_cols = hub75["Cols"].as(64);
+ portduino_config.hub75_chain_length = hub75["ChainLength"].as(1);
+ portduino_config.hub75_parallel = hub75["Parallel"].as(1);
+ portduino_config.hub75_pwm_bits = hub75["PWMBits"].as(11);
+ portduino_config.hub75_pwm_lsb_nanoseconds = hub75["PWMLSBNanoseconds"].as(130);
+ portduino_config.hub75_brightness = hub75["Brightness"].as(100);
+ portduino_config.hub75_scan_mode = hub75["ScanMode"].as(0);
+ portduino_config.hub75_row_address_type = hub75["RowAddressType"].as(0);
+ portduino_config.hub75_multiplexing = hub75["Multiplexing"].as(0);
+ portduino_config.hub75_disable_hardware_pulsing = hub75["DisableHardwarePulsing"].as(false);
+ portduino_config.hub75_show_refresh_rate = hub75["ShowRefreshRate"].as(false);
+ portduino_config.hub75_inverse_colors = hub75["InverseColors"].as(false);
+ portduino_config.hub75_led_rgb_sequence = hub75["RGBSequence"].as("RGB");
+ portduino_config.hub75_pixel_mapper_config = hub75["PixelMapper"].as("");
+ portduino_config.hub75_panel_type = hub75["PanelType"].as("");
+ portduino_config.hub75_limit_refresh_rate_hz = hub75["LimitRefreshRateHz"].as(0);
+ portduino_config.hub75_gpio_slowdown = hub75["GPIOSlowdown"].as(1);
+ // The BaseUI framebuffer geometry is the full panel size in pixels.
+ portduino_config.displayWidth = portduino_config.hub75_cols * portduino_config.hub75_chain_length;
+ portduino_config.displayHeight = portduino_config.hub75_rows * portduino_config.hub75_parallel;
+ }
}
if (yamlConfig["Touchscreen"]) {
if (yamlConfig["Touchscreen"]["Module"].as("") == "XPT2046")
diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h
index 050aaaf87a..b5a47787e1 100644
--- a/src/platform/portduino/PortduinoGlue.h
+++ b/src/platform/portduino/PortduinoGlue.h
@@ -34,7 +34,7 @@ inline const std::unordered_map configProducts = {
{"RAK6421-13300-S1", "lora-RAK6421-13300-slot1.yaml"},
{"RAK6421-13300-S2", "lora-RAK6421-13300-slot2.yaml"}};
-enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d };
+enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d, hub75 };
enum touchscreen_modules { no_touchscreen, xpt2046, stmpe610, gt911, ft5x06 };
enum portduino_log_level { level_error, level_warn, level_info, level_debug, level_trace };
enum lora_module_enum {
@@ -83,7 +83,7 @@ extern struct portduino_config_struct {
std::map screen_names = {{x11, "X11"}, {fb, "FB"}, {st7789, "ST7789"},
{st7735, "ST7735"}, {st7735s, "ST7735S"}, {st7796, "ST7796"},
{ili9341, "ILI9341"}, {ili9342, "ILI9342"}, {ili9486, "ILI9486"},
- {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}};
+ {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}, {hub75, "HUB75"}};
lora_module_enum lora_module;
bool has_rfswitch_table = false;
@@ -147,6 +147,29 @@ extern struct portduino_config_struct {
pinMapping displayBacklightPWMChannel = {"Display", "BacklightPWMChannel"};
pinMapping displayReset = {"Display", "Reset"};
+ // Display -> HUB75 (Raspberry Pi RGB matrix via hzeller/rpi-rgb-led-matrix).
+ // These mirror rgb_matrix::RGBMatrix::Options + RuntimeOptions; the library owns the GPIO
+ // pins (chosen by hub75_hardware_mapping), so there are no per-pin mappings here.
+ std::string hub75_hardware_mapping = "regular";
+ int hub75_rows = 64;
+ int hub75_cols = 64;
+ int hub75_chain_length = 1;
+ int hub75_parallel = 1;
+ int hub75_pwm_bits = 11;
+ int hub75_pwm_lsb_nanoseconds = 130;
+ int hub75_brightness = 100; // percent, 1..100
+ int hub75_scan_mode = 0;
+ int hub75_row_address_type = 0;
+ int hub75_multiplexing = 0;
+ bool hub75_disable_hardware_pulsing = false;
+ bool hub75_show_refresh_rate = false;
+ bool hub75_inverse_colors = false;
+ std::string hub75_led_rgb_sequence = "RGB";
+ std::string hub75_pixel_mapper_config = "";
+ std::string hub75_panel_type = "";
+ int hub75_limit_refresh_rate_hz = 0;
+ int hub75_gpio_slowdown = 1; // RuntimeOptions; higher for faster Pis / long cables
+
// Touchscreen
std::string touchscreen_spi_dev = "";
int touchscreen_spi_dev_int = 0;
@@ -423,6 +446,30 @@ extern struct portduino_config_struct {
out << YAML::Key << "OffsetRotate" << YAML::Value << displayOffsetRotate;
+ if (displayPanel == hub75) {
+ out << YAML::Key << "HUB75" << YAML::Value << YAML::BeginMap;
+ out << YAML::Key << "HardwareMapping" << YAML::Value << hub75_hardware_mapping;
+ out << YAML::Key << "Rows" << YAML::Value << hub75_rows;
+ out << YAML::Key << "Cols" << YAML::Value << hub75_cols;
+ out << YAML::Key << "ChainLength" << YAML::Value << hub75_chain_length;
+ out << YAML::Key << "Parallel" << YAML::Value << hub75_parallel;
+ out << YAML::Key << "PWMBits" << YAML::Value << hub75_pwm_bits;
+ out << YAML::Key << "PWMLSBNanoseconds" << YAML::Value << hub75_pwm_lsb_nanoseconds;
+ out << YAML::Key << "Brightness" << YAML::Value << hub75_brightness;
+ out << YAML::Key << "ScanMode" << YAML::Value << hub75_scan_mode;
+ out << YAML::Key << "RowAddressType" << YAML::Value << hub75_row_address_type;
+ out << YAML::Key << "Multiplexing" << YAML::Value << hub75_multiplexing;
+ out << YAML::Key << "DisableHardwarePulsing" << YAML::Value << hub75_disable_hardware_pulsing;
+ out << YAML::Key << "ShowRefreshRate" << YAML::Value << hub75_show_refresh_rate;
+ out << YAML::Key << "InverseColors" << YAML::Value << hub75_inverse_colors;
+ out << YAML::Key << "RGBSequence" << YAML::Value << hub75_led_rgb_sequence;
+ out << YAML::Key << "PixelMapper" << YAML::Value << hub75_pixel_mapper_config;
+ out << YAML::Key << "PanelType" << YAML::Value << hub75_panel_type;
+ out << YAML::Key << "LimitRefreshRateHz" << YAML::Value << hub75_limit_refresh_rate_hz;
+ out << YAML::Key << "GPIOSlowdown" << YAML::Value << hub75_gpio_slowdown;
+ out << YAML::EndMap; // HUB75
+ }
+
out << YAML::EndMap; // Display
}
diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h
index b1698a4eb0..a35cecfb3e 100644
--- a/src/platform/portduino/architecture.h
+++ b/src/platform/portduino/architecture.h
@@ -30,4 +30,12 @@
#define TB_LEFT (uint8_t) portduino_config.tbLeftPin.pin
#define TB_RIGHT (uint8_t) portduino_config.tbRightPin.pin
#define TB_PRESS (uint8_t) portduino_config.tbPressPin.pin
+#endif
+
+// HUB75 RGB-matrix panel support on Raspberry Pi (Portduino) turns on automatically when the
+// hzeller/rpi-rgb-led-matrix library is installed - its headers land on the include path via
+// `pkg-config rgbmatrix` (wired up in variants/native/portduino/platformio.ini). When absent,
+// HAS_HUB75_NATIVE stays undefined and the backend compiles out. See src/graphics/HUB75Display.cpp.
+#if defined(ARCH_PORTDUINO) && __has_include()
+#define HAS_HUB75_NATIVE 1
#endif
\ No newline at end of file
diff --git a/test/native-suite-count b/test/native-suite-count
index f5c89552bd..8f92bfdd49 100644
--- a/test/native-suite-count
+++ b/test/native-suite-count
@@ -1 +1 @@
-32
+35
diff --git a/test/test_breakout/test_main.cpp b/test/test_breakout/test_main.cpp
new file mode 100644
index 0000000000..1ca2da5e2d
--- /dev/null
+++ b/test/test_breakout/test_main.cpp
@@ -0,0 +1,103 @@
+#include "TestUtil.h"
+#include "modules/games/Breakout.h"
+#include
+
+// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on
+// a straight-up serve, and the ball staying within the board. No device globals or display stack.
+
+static const uint32_t kSeed = 0xC0FFEEu;
+
+void test_reset_initialState()
+{
+ BreakoutGame game;
+ game.reset(kSeed);
+ TEST_ASSERT_TRUE(game.isPlaying());
+ TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives());
+ TEST_ASSERT_EQUAL_UINT8(1, game.level());
+ TEST_ASSERT_EQUAL_UINT32(0, game.score());
+ // Every brick present at the start.
+ TEST_ASSERT_EQUAL_UINT16(static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS, game.bricksRemaining());
+ // Paddle centred, ball above it and inside the board.
+ TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX());
+ TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
+ TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H);
+}
+
+void test_paddle_clampsToEdges()
+{
+ BreakoutGame game;
+ game.reset(kSeed);
+ for (int i = 0; i < 100; i++)
+ game.moveLeft();
+ TEST_ASSERT_EQUAL_INT16(0, game.paddleX());
+ for (int i = 0; i < 100; i++)
+ game.moveRight();
+ TEST_ASSERT_EQUAL_INT16(BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W, game.paddleX());
+}
+
+void test_serve_clearsABrickAndScores()
+{
+ BreakoutGame game;
+ game.reset(kSeed);
+ // The ball serves upward from just above the paddle straight into the brick field; within a
+ // few dozen steps it must clear at least one brick and score.
+ for (int i = 0;
+ i < 60 && game.bricksRemaining() == static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++)
+ game.step();
+ TEST_ASSERT_TRUE(game.bricksRemaining() < static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS);
+ TEST_ASSERT_TRUE(game.score() > 0);
+}
+
+void test_ball_staysInBounds()
+{
+ BreakoutGame game;
+ game.reset(kSeed);
+ // Drive the paddle to follow the ball so the game keeps going, and check the ball never leaves
+ // the board horizontally across a long run.
+ for (int i = 0; i < 500 && game.isPlaying(); i++) {
+ if (game.ballX() < game.paddleX())
+ game.moveLeft();
+ else
+ game.moveRight();
+ game.step();
+ TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W);
+ TEST_ASSERT_TRUE(game.ballY() >= 0);
+ }
+}
+
+void test_deadGame_stepIsNoOp()
+{
+ BreakoutGame game;
+ game.reset(kSeed);
+ // Park the paddle in a corner and never move it; the ball is eventually lost every life.
+ game.moveLeft();
+ for (int i = 0; i < 20000 && game.isPlaying(); i++) {
+ for (int j = 0; j < 40; j++) // hold the paddle pinned left
+ game.moveLeft();
+ game.step();
+ }
+ TEST_ASSERT_FALSE(game.isPlaying());
+ const uint32_t scoreBefore = game.score();
+ TEST_ASSERT_FALSE(game.step()); // stays dead, no further change
+ TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
+}
+
+void setUp(void) {}
+
+void tearDown(void) {}
+
+extern "C" {
+void setup()
+{
+ initializeTestEnvironment();
+ UNITY_BEGIN();
+ RUN_TEST(test_reset_initialState);
+ RUN_TEST(test_paddle_clampsToEdges);
+ RUN_TEST(test_serve_clearsABrickAndScores);
+ RUN_TEST(test_ball_staysInBounds);
+ RUN_TEST(test_deadGame_stepIsNoOp);
+ exit(UNITY_END());
+}
+
+void loop() {}
+}
diff --git a/test/test_chirpy/test_main.cpp b/test/test_chirpy/test_main.cpp
new file mode 100644
index 0000000000..487b2830f3
--- /dev/null
+++ b/test/test_chirpy/test_main.cpp
@@ -0,0 +1,100 @@
+#include "TestUtil.h"
+#include "modules/games/ChirpyRunner.h"
+#include
+
+// Pure-logic tests for ChirpyRunnerGame: initial state, jump lifts Chirpy off the ground,
+// obstacles spawn and a collision ends the run, and a dead game is inert. No device globals.
+
+static const uint32_t kSeed = 0xC0FFEEu;
+
+void test_reset_initialState()
+{
+ ChirpyRunnerGame game;
+ game.reset(kSeed);
+ TEST_ASSERT_TRUE(game.isPlaying());
+ TEST_ASSERT_EQUAL_UINT32(0, game.score());
+ TEST_ASSERT_TRUE(game.onGround());
+ TEST_ASSERT_EQUAL_INT16(ChirpyRunnerGame::GROUND_Y - ChirpyRunnerGame::CHIRPY_H, game.chirpyY());
+}
+
+void test_jump_liftsChirpy()
+{
+ ChirpyRunnerGame game;
+ game.reset(kSeed);
+ const int16_t groundY = game.chirpyY();
+ game.jump();
+ game.step();
+ TEST_ASSERT_FALSE(game.onGround());
+ TEST_ASSERT_TRUE(game.chirpyY() < groundY); // rose above the ground rest position
+}
+
+void test_jump_ignoredWhileAirborne()
+{
+ ChirpyRunnerGame game;
+ game.reset(kSeed);
+ game.jump();
+ game.step();
+ const int16_t yAfterFirst = game.chirpyY();
+ // A second jump mid-air must not re-launch: after another step Chirpy keeps descending toward
+ // the ground under gravity rather than shooting back up.
+ game.jump();
+ game.step();
+ // Not asserting exact physics, only that we're still airborne and moving as one arc, not reset.
+ TEST_ASSERT_FALSE(game.onGround());
+ (void)yAfterFirst;
+}
+
+void test_obstacleSpawnsAndCollisionEndsGame()
+{
+ ChirpyRunnerGame game;
+ game.reset(kSeed);
+ // One step spawns the first obstacle.
+ game.step();
+ bool any = false;
+ for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++)
+ any = any || game.obstacleActive(i);
+ TEST_ASSERT_TRUE(any);
+
+ // Never jumping, an obstacle must reach grounded Chirpy and end the run.
+ int steps = 0;
+ while (game.isPlaying() && steps < 2000) {
+ game.step();
+ steps++;
+ }
+ TEST_ASSERT_FALSE(game.isPlaying());
+}
+
+void test_deadGame_stepIsNoOp()
+{
+ ChirpyRunnerGame game;
+ game.reset(kSeed);
+ int steps = 0;
+ while (game.isPlaying() && steps < 2000) {
+ game.step();
+ steps++;
+ }
+ TEST_ASSERT_FALSE(game.isPlaying());
+ const uint32_t scoreBefore = game.score();
+ TEST_ASSERT_FALSE(game.step()); // stays dead, no further change
+ TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
+}
+
+void setUp(void) {}
+
+void tearDown(void) {}
+
+extern "C" {
+void setup()
+{
+ initializeTestEnvironment();
+ UNITY_BEGIN();
+ RUN_TEST(test_reset_initialState);
+ RUN_TEST(test_jump_liftsChirpy);
+ RUN_TEST(test_jump_ignoredWhileAirborne);
+ RUN_TEST(test_obstacleSpawnsAndCollisionEndsGame);
+ RUN_TEST(test_deadGame_stepIsNoOp);
+ exit(UNITY_END());
+}
+
+void loop() {}
+}
diff --git a/test/test_snake/test_main.cpp b/test/test_snake/test_main.cpp
new file mode 100644
index 0000000000..8668e66a53
--- /dev/null
+++ b/test/test_snake/test_main.cpp
@@ -0,0 +1,180 @@
+#include "TestUtil.h"
+#include "modules/games/Snake.h"
+#include
+
+// Pure-logic tests for SnakeGame: ring-buffer advance, reversal rejection, wall/self collision,
+// growth on eat, and food-placement validity. No device globals or display stack required.
+
+static const uint32_t kSeed = 0xC0FFEEu;
+
+// Count how many board cells the snake currently occupies (cross-check for len()).
+static uint16_t countOccupied(const SnakeGame &game)
+{
+ uint16_t n = 0;
+ for (uint8_t y = 0; y < SnakeGame::GRID_H; y++)
+ for (uint8_t x = 0; x < SnakeGame::GRID_W; x++)
+ if (game.occupied(x, y))
+ n++;
+ return n;
+}
+
+static void test_reset_initialState()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+
+ TEST_ASSERT_TRUE(game.isPlaying());
+ TEST_ASSERT_FALSE(game.isWon());
+ TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length());
+ TEST_ASSERT_EQUAL_UINT32(0u, game.score());
+ TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_RIGHT, game.direction());
+
+ // Head spawns at board centre; the whole test file relies on this anchor.
+ SnakeGame::Cell head = game.head();
+ TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_W / 2, head.x);
+ TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_H / 2, head.y);
+
+ // Exactly START_LEN cells occupied, and the head is one of them.
+ TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
+ TEST_ASSERT_TRUE(game.occupied(head.x, head.y));
+}
+
+static void test_food_isValidAndOffBody()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+ SnakeGame::Cell food = game.food();
+ TEST_ASSERT_TRUE(food.x < SnakeGame::GRID_W);
+ TEST_ASSERT_TRUE(food.y < SnakeGame::GRID_H);
+ TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); // food never spawns on the snake
+}
+
+static void test_setDirection_rejectsReversal()
+{
+ SnakeGame game;
+ game.reset(kSeed); // heading right
+
+ TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); // 180 reversal -> rejected
+ TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_UP)); // perpendicular -> ok
+ TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_RIGHT)); // same as committed dir -> ok (no-op)
+
+ // A double-input within one tick can't chain into a reversal: after latching UP, LEFT is
+ // still checked against the committed RIGHT and rejected, so the neck stays safe.
+ game.setDirection(SnakeGame::DIR_UP);
+ TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT));
+}
+
+static void test_step_movesAndTailFollows()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+ SnakeGame::Cell head = game.head();
+ game.placeFoodAt(0, 0); // corner, off the snake -> guaranteed non-eating step
+
+ TEST_ASSERT_TRUE(game.step());
+ SnakeGame::Cell newHead = game.head();
+ TEST_ASSERT_EQUAL_UINT8(head.x + 1, newHead.x); // moved one cell right
+ TEST_ASSERT_EQUAL_UINT8(head.y, newHead.y);
+ TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); // length unchanged when not eating
+ TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game));
+ TEST_ASSERT_EQUAL_UINT32(0u, game.score());
+}
+
+static void test_eat_growsAndScores()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+ SnakeGame::Cell head = game.head();
+ game.placeFoodAt(head.x + 1, head.y); // food directly ahead
+
+ TEST_ASSERT_TRUE(game.step());
+ TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, game.length()); // grew by one
+ TEST_ASSERT_EQUAL_UINT32(1u, game.score());
+ TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, countOccupied(game));
+
+ // A fresh food was placed and is not on the snake.
+ SnakeGame::Cell food = game.food();
+ TEST_ASSERT_FALSE(game.occupied(food.x, food.y));
+}
+
+static void test_wallCollision_endsGame()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+ game.placeFoodAt(0, 0);
+ game.setDirection(SnakeGame::DIR_UP); // head is at mid-height; drive straight up into the wall
+
+ bool alive = true;
+ int guard = 0;
+ while (alive && guard++ < SnakeGame::GRID_H + 4) {
+ game.placeFoodAt(0, 0); // keep food out of the way each tick
+ alive = game.step();
+ }
+ TEST_ASSERT_FALSE(alive);
+ TEST_ASSERT_FALSE(game.isPlaying());
+}
+
+static void test_selfCollision_endsGame()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+ TEST_ASSERT_EQUAL_UINT8(16, game.head().x); // anchor the deterministic path below
+ TEST_ASSERT_EQUAL_UINT8(6, game.head().y);
+
+ // Grow to length 5 along a straight horizontal line (cells (14..18, 6)).
+ game.placeFoodAt(17, 6);
+ TEST_ASSERT_TRUE(game.step());
+ game.placeFoodAt(18, 6);
+ TEST_ASSERT_TRUE(game.step());
+ TEST_ASSERT_EQUAL_UINT16(5, game.length());
+
+ // Curl back on itself: DOWN, LEFT, then UP re-enters an occupied body cell.
+ game.setDirection(SnakeGame::DIR_DOWN);
+ game.placeFoodAt(0, 0);
+ TEST_ASSERT_TRUE(game.step());
+ game.setDirection(SnakeGame::DIR_LEFT);
+ game.placeFoodAt(0, 0);
+ TEST_ASSERT_TRUE(game.step());
+ game.setDirection(SnakeGame::DIR_UP);
+ game.placeFoodAt(0, 0);
+ TEST_ASSERT_FALSE(game.step()); // bites its own body
+ TEST_ASSERT_FALSE(game.isPlaying());
+}
+
+static void test_deadGame_stepIsNoOp()
+{
+ SnakeGame game;
+ game.reset(kSeed);
+ game.setDirection(SnakeGame::DIR_UP);
+ for (int i = 0; i < SnakeGame::GRID_H + 4; i++) {
+ game.placeFoodAt(0, 0);
+ game.step();
+ }
+ TEST_ASSERT_FALSE(game.isPlaying());
+ uint32_t scoreBefore = game.score();
+ TEST_ASSERT_FALSE(game.step()); // stays dead, no state change
+ TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score());
+}
+
+void setUp(void) {}
+
+void tearDown(void) {}
+
+extern "C" {
+void setup()
+{
+ initializeTestEnvironment();
+ UNITY_BEGIN();
+ RUN_TEST(test_reset_initialState);
+ RUN_TEST(test_food_isValidAndOffBody);
+ RUN_TEST(test_setDirection_rejectsReversal);
+ RUN_TEST(test_step_movesAndTailFollows);
+ RUN_TEST(test_eat_growsAndScores);
+ RUN_TEST(test_wallCollision_endsGame);
+ RUN_TEST(test_selfCollision_endsGame);
+ RUN_TEST(test_deadGame_stepIsNoOp);
+ exit(UNITY_END());
+}
+
+void loop() {}
+}
diff --git a/variants/esp32s3/visualizer-hub75/platformio.ini b/variants/esp32s3/visualizer-hub75/platformio.ini
new file mode 100644
index 0000000000..fb321da265
--- /dev/null
+++ b/variants/esp32s3/visualizer-hub75/platformio.ini
@@ -0,0 +1,20 @@
+; Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) + 128x64 HUB75 matrix + Wio-SX1262 - DIY
+[env:visualizer-hub75]
+extends = esp32s3_base
+board = esp32-s3-devkitc-1
+board_check = true
+board_build.partitions = default_16MB.csv
+board_level = extra
+board_upload.flash_size = 16MB ; N16R8 -> 16MB flash
+board_build.arduino.memory_type = qio_opi ; N16R8 -> 8MB octal (OPI) PSRAM
+build_flags =
+ ${esp32s3_base.build_flags}
+ -D VISUALIZER_HUB75
+ -D BOARD_HAS_PSRAM
+ -D ARDUINO_USB_MODE=1
+ -D ARDUINO_USB_CDC_ON_BOOT=1
+ -I variants/esp32s3/visualizer-hub75
+lib_deps =
+ ${esp32s3_base.lib_deps}
+ # renovate: datasource=github-tags depName=ESP32-HUB75-MatrixPanel-I2S-DMA packageName=mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA
+ https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA/archive/refs/tags/3.0.14.zip
diff --git a/variants/esp32s3/visualizer-hub75/variant.h b/variants/esp32s3/visualizer-hub75/variant.h
new file mode 100644
index 0000000000..0481024e38
--- /dev/null
+++ b/variants/esp32s3/visualizer-hub75/variant.h
@@ -0,0 +1,66 @@
+// Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) driving a 128x64 HUB75 RGB matrix
+// via the seengreat "RGB Matrix Adapter Board (E)" Rev. 2.2 (V2.x), plus a
+// Wio-SX1262 (for XIAO, Seeed p/n 6379) LoRa header board.
+
+#define BUTTON_PIN 0
+
+#define I2C_SDA 41
+#define I2C_SCL 42
+
+// --- Display: HUB75 via seengreat Shield (V2.x, hard-wired) -----------------
+#define HAS_SPI_TFT 1 // enable the color BaseUI path (TFTColorRegions/theming)
+#define DISPLAY_FORCE_SMALL_FONTS // 128x64 panel: keep OLED-sized fonts, not big TFT fonts
+#define USE_HUB75 // select HUB75Display in Screen.cpp
+
+#define TFT_WIDTH 128
+#define TFT_HEIGHT 64
+#define HUB75_BRIGHTNESS_DEFAULT 180
+
+// RGB data lines.
+// This panel is BGR: R and B are swapped per half vs. the nominal seengreat
+// mapping (R1<->B1, R2<->B2), verified at bring-up. Software-only fix; the
+// shield board is unchanged. G / address / control lines stay as routed.
+#define HUB75_R1 17
+#define HUB75_G1 8
+#define HUB75_B1 18
+#define HUB75_R2 15
+#define HUB75_G2 1
+#define HUB75_B2 16
+// Row-address lines (1/32 scan -> A..E)
+#define HUB75_A 7
+#define HUB75_B 48
+#define HUB75_C 6
+#define HUB75_D 47
+#define HUB75_E 2
+// Control lines
+#define HUB75_CLK 5
+#define HUB75_LAT 21
+#define HUB75_OE 4
+
+// --- Radio: Wio-SX1262 for XIAO (header board, Seeed p/n 6379) ---------------
+#define USE_SX1262
+
+#define LORA_SCK 12
+#define LORA_MISO 13
+#define LORA_MOSI 11
+#define LORA_CS 10
+#define LORA_RESET 9
+#define LORA_DIO1 40
+
+#define SX126X_CS LORA_CS
+#define SX126X_SCK LORA_SCK
+#define SX126X_MOSI LORA_MOSI
+#define SX126X_MISO LORA_MISO
+#define SX126X_DIO1 LORA_DIO1
+#define SX126X_BUSY 14
+#define SX126X_RESET LORA_RESET
+
+#define SX126X_DIO2_AS_RF_SWITCH
+#define SX126X_DIO3_TCXO_VOLTAGE 1.8
+
+#define SX126X_RXEN 39
+#define SX126X_TXEN RADIOLIB_NC
+
+#define SX126X_MAX_POWER 22
+
+#define LED_HEARTBEAT 38
diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini
index 00d2a3a94d..044f64166e 100644
--- a/variants/native/portduino.ini
+++ b/variants/native/portduino.ini
@@ -56,6 +56,8 @@ build_flags_common =
-luv
-std=gnu17
-std=gnu++17
+ -DBASEUI_HAS_GAMES=1
+ -DMAX_TFT_COLOR_REGIONS=64
build_flags =
${portduino_base.build_flags_common}
diff --git a/variants/native/portduino/HUB75Native.h b/variants/native/portduino/HUB75Native.h
new file mode 100644
index 0000000000..3b9e2728eb
--- /dev/null
+++ b/variants/native/portduino/HUB75Native.h
@@ -0,0 +1,57 @@
+#pragma once
+
+#include "configuration.h"
+
+#if defined(HAS_HUB75_NATIVE)
+
+#include
+
+#ifndef HUB75_BRIGHTNESS_DEFAULT
+#define HUB75_BRIGHTNESS_DEFAULT 100 // rpi-rgb-led-matrix brightness is a 1..100 percentage
+#endif
+
+namespace rgb_matrix
+{
+class RGBMatrix;
+class FrameCanvas;
+} // namespace rgb_matrix
+
+/**
+ * Native (Raspberry Pi / Portduino) HUB75 RGB-matrix display backend.
+ *
+ * Drives the panel with hzeller/rpi-rgb-led-matrix. Like the ESP32 HUB75Display it plugs into the
+ * BaseUI color path by subclassing OLEDDisplay and expanding the mono framebuffer into RGB via the
+ * shared TFTColorRegions/TFTPalette helpers. It is a separate class (not shared with the ESP32
+ * driver) so no rpi-rgb-led-matrix code lives in src/graphics/. All wiring/tuning comes from
+ * config.yaml (portduino_config.hub75_*); the library owns the GPIO pins.
+ *
+ * Bodies live in src/platform/portduino/HUB75Native.cpp (compiled native-only).
+ */
+class HUB75Native : public OLEDDisplay
+{
+ public:
+ HUB75Native(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus);
+ ~HUB75Native();
+
+ // Redraw the whole panel from the BaseUI buffer (region-aware color expansion).
+ void display() override;
+
+ void setDisplayBrightness(uint8_t brightness);
+
+ protected:
+ int getBufferOffset(void) override { return 0; }
+
+ void sendCommand(uint8_t com) override;
+
+ bool connect() override;
+
+ private:
+ rgb_matrix::RGBMatrix *matrix = nullptr;
+ // Offscreen buffer for tear-free updates: display() draws here, then SwapOnVSync() atomically
+ // presents it. Without this, writing into the live canvas while the refresh thread scans it
+ // tears (worse when display() is contended during packet TX/RX).
+ rgb_matrix::FrameCanvas *offscreen = nullptr;
+ uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT; // 1..100
+};
+
+#endif // HAS_HUB75_NATIVE
diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini
index e71386b670..5694a0d3b5 100644
--- a/variants/native/portduino/platformio.ini
+++ b/variants/native/portduino/platformio.ini
@@ -20,6 +20,11 @@ build_flags = ${native_base.build_flags}
!pkg-config --libs openssl --silence-errors || :
!pkg-config --cflags --libs sdl2 --silence-errors || :
!pkg-config --cflags --libs libbsd-overlay --silence-errors || :
+ ; Optional HUB75 RGB-matrix support on Raspberry Pi via hzeller/rpi-rgb-led-matrix.
+ ; When the lib is installed (provides rgbmatrix.pc), these add its include/link flags and
+ ; __has_include() enables HAS_HUB75_NATIVE (see configuration.h). Absent -> no-op.
+ !pkg-config --cflags rgbmatrix --silence-errors || :
+ !pkg-config --libs rgbmatrix --silence-errors || :
[env:native-tft]
extends = native_base