C++17 on PlatformIO, with LVGL 9.2 for the UI. On the board: Arduino-ESP32 and LovyanGFX for the display and touch. In the simulator: SDL2 for the window and libcurl for HTTPS.
Layout#
src/
main.cpp The loop: input, App, then LVGL
app/ App: owns the services and screens; every flow decision lives here
app.cpp startup, setup, sign-in, profile, navigation, settings
app_session.cpp access tokens, and running requests with a fresh one
app_beetle.cpp hunt, cheese, craft, and the cooldowns
app_icons.cpp item icons: from flash, the cache, or downloaded
app_pokes.cpp poke notifications, the list, poking back
app_power.cpp Wi-Fi link, screen off, sleep
menu_config.h the home menu's tiles
ui/ Screen base, the screen stack, theme, shared widgets
ui/screens/ one class per screen
account/ remilia.net: sign-in (Keycloak), profile, the beetle game, pokes
services/ interfaces each platform implements: storage, Wi-Fi, HTTP, setup network
portal/ the setup web page, shared by the board and the sim
input/ touch (and the sim's debug keys), as one stream of input events
media/ images decoded at runtime, buzzer tunes, LED effects
hal/ hardware primitives (time, display, sleep...)
platform/esp32/ the board's implementations
platform/sim/ the simulator's implementations
util/ Job: runs network and decoding work on a background thread
fonts/ generated by scripts/fonts.sh (committed)
assets/ generated from assets/ at build time (not committed)
assets/ images built into the firmware, and their sources
include/ board_config.h (pins), lv_conf.h (LVGL settings)
lib/libwebp/ Google's libwebp, decoder only
scripts/ build steps and asset tools
Only platform/ differs between the board and the simulator. Everything else compiles for both.
Rules#
- Screens are views. They draw and call
Appmethods. They never touch services or switch screens on their own. - Navigation is deferred. The screen stack applies push, pop and replace in its
update(), so a screen can close itself from its own button. - Each screen has its own focus group, so button focus never leaks between screens.
- All input goes through
Controller. Focusable widgets handle their own clicks. Other input goes to the screen'sonInput()first, then to App: Back closes the top screen. - Slow work runs in a
Job(network, image decoding) on the other core. The main loop polls for the result; jobs never touch LVGL. - Tunables go at the top of each file as named constants: sizes, timings, colors, limits.
Adding a screen#
- Create
src/ui/screens/foo_screen.{h,cpp}: subclassScreen, implementname()andbuild(). Use the helpers inui/widgets.hand the colors inui/theme.h. - Add an
openFoo()toAppthat pushes it:screens_.push(std::make_unique<FooScreen>(*this)). - To put it on the home menu, add a line to
src/app/menu_config.h.
Images#
Put PNGs in assets/ (lowercase, digits, _). Each build turns assets/foo.png into img_foo (include assets/assets.h). They're stored as raw screen pixels (RGB565, plus an alpha plane if the PNG has transparency), so export them at the size they're shown at. Numbered files (foo_00.png, foo_01.png...) also get a frame list for animations.
Item icons in assets/item_icons/ are built in too, so common items show instantly. scripts/item_icons.py <request.curl> downloads any new ones. It needs a /cards request copied as cURL from the browser.
Fonts#
scripts/fonts.sh regenerates src/fonts/ from assets/fonts/. It needs Node (for lv_font_conv). Run it when the UI needs new characters or sizes.
