ESP32-S3
The ESP32-S3 example is the reference Flow A board: a real React reconciler on QuickJS, with the
JavaScript heap in PSRAM. Its example project lives at
examples/esp32/esp32-s3
and is written to be copied out and adapted.
Board: Waveshare ESP32-S3-Touch-LCD-7. A 7" 800×480 RGB-parallel IPS panel, 8 MB octal PSRAM, 16 MB flash, a GT911 capacitive touch controller on I²C, and a CH422G I²C expander that drives the panel's reset, backlight, and touch-reset lines. Two USB-C ports: "UART" (a CH343 bridge, used for flashing) and "USB" (the chip's native USB, used for hot reload).
How it is put together
The firmware and your app are two independent artifacts. The firmware brings up the panel, the
touch controller, and QuickJS, then loads the app as a config container (app.erpkg) from a
dedicated flash partition: memory-mapped, checksum-verified, and stamped with the QuickJS version it
was built for. To ship a new UI you write one partition; the firmware is untouched.
main/main.c host: PSRAM JS heap, map + load the config partition, frame loop, touch
main/board.c panel + touch bring-up for this board (CH422G, RGB panel, GT911)
CMakeLists.txt top-level IDF project; fetches QuickJS-ng and, when copied out, embedded-react
partitions.csv factory (firmware) + a 2 MB 'config' data partition for app.erpkg
sdkconfig.defaults ESP32-S3, octal PSRAM, 16 MB flash, a 64 KB main task stack
Dependencies are pulled by CMake FetchContent at configure time, nothing is vendored. Inside the
repository the example uses the local engine and bridge; copied out on its own, it fetches them from
GitHub at a pinned tag. QuickJS-ng is always fetched, pinned to v0.15.0, and that pin matters:
bytecode is specific to the QuickJS version, and the loader rejects a container built for another.
Flow A needs QuickJS, which is a plain CMake project rather than an ESP-IDF component, so
idf.py add-dependency cannot manage it. FetchContent pulls the engine, the bridge, and QuickJS
together. The registry holds the engine and the Flow B runtime, but not the bridge or the display backend; see
Installation.
Build and flash the firmware
You need ESP-IDF v6.x installed and exported (. $IDF_PATH/export.sh); the example is validated on
v6.0.1. The first configure downloads the dependencies, so it needs git and a network once.
cd examples/esp32/esp32-s3
idf.py set-target esp32s3
idf.py build flash monitor # picks the port automatically, or pass -p PORT
Flash through the UART port. With no app loaded, the panel shows "No config loaded". That is the firmware working.
Build and flash your app
From your own project (a create-embedded-react scaffold or anything that depends on
embedded-react):
npx embedded-react build # → dist/app.erpkg
parttool.py write_partition --partition-name=config --input dist/app.erpkg
parttool.py ships with ESP-IDF and finds the port itself. The board restarts into your app. From now
on, only this step is needed for a UI change.
The log should read roughly:
I (xxx) embedded-react: embedded-react ESP32-S3 host
I (xxx) embedded-react: PSRAM free: 6…… internal free: …
I (xxx) board: RGB panel up: 800x480
I (xxx) embedded-react: display backend active
I (xxx) board: GT911 up: product id '911 '
I (xxx) embedded-react: config partition mapped (2097152 bytes) — loading container
I (xxx) embedded-react: config loaded
I (xxx) js: React mounted at 800x480
I (xxx) embedded-react: alive: 120 frames, …
React mounted plus a lit panel is the win. If the config is missing or rejected, the panel says
why (No config loaded, Couldn't load config: <reason>); if the panel itself fails to initialise,
the host falls back to a headless backend so the JavaScript side still runs and logs.
Inside the repository, cd packages/embedded-react && npm run pack packs one of the demos instead
(npm run pack -- watch-face picks another), and the container lands in
packages/embedded-react/dist/app.erpkg.
Hot reload over USB
A firmware built with idf.py -DER_HOTRELOAD=1 build flash accepts a fresh app over the board's
native USB port on every save, with the running UI staying on screen until the new one is in and
component state preserved. Plug in both USB ports, then from your project:
npm i serialport # once: the optional native dependency for device upload
npx embedded-react dev --device # auto-detects the ESP32's USB-Serial-JTAG
It is off by default and meant for development only; a release firmware should be built without it. Hot reload explains how it works and what it costs.
Two board-specific things make the native USB port work, both already wired up here: the console is
bound to USB-Serial-JTAG (CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y), which is what installs the driver
the receiver reads from, and the CH422G's USB_SEL line is driven low in board.c, because the
native USB pins are muxed with a CAN transceiver and the port never enumerates until the firmware
selects USB. If you adapt the example to a board with the same expander, keep that line.
The backend
backends/esp32-lcd does the fills, copies and blends on the CPU (on the S3's SIMD unit for the
blend loops) and lets the LCD peripheral DMA the framebuffer out; the host calls
er_esp32_lcd_present() after each er_commit(). With an unrotated RGB565 panel that exposes two or
three framebuffers it can composite straight into them (ER_LCD_DIRECT); three framebuffers means
a frame never waits on the display. This example asks the panel for one framebuffer, so it takes the
copy path: the engine draws into a separate canonical framebuffer that is copied to the panel on
present. A rotated panel or an ARGB8888 build always takes the copy path.
Adapting the example to another board
Two pieces are board-specific. main/board.c and board.h bring up this panel and touch
controller; the render backend (components/esp32-lcd-backend, which is backends/esp32-lcd) pushes
pixels through an esp_lcd panel handle.
- For another RGB or I80 parallel panel, keep the backend and rewrite
board.cfor your panel and touch chip. - For an SPI panel (ILI9341, ST7789, and the like), swap the backend for
backends/esp32-spi-lcd, the banded RGB565 backend the CYD example uses.
Things to know
- The host must present.
er_commit()paints into the backend's framebuffer and nothing more;main.ccallser_esp32_lcd_present()after each commit to flush the dirty region to the panel. Forget it and the screen stays black with a working panel. - Panel timings (porches, pixel clock) and the data-pin to colour-channel map live in
board.c. A shifted or rolling image means porch or clock; swapped red and blue means byte order. - The CH422G expander drives the backlight, LCD-reset, and touch-reset lines from
board.c. If the backlight stays off, the config byte and output mask there are what to check. - The GT911 shares the panel's I²C bus; taps in the wrong place usually mean an axis swap.
- Task stack. QuickJS and the reconciler recurse deeply; the main task stack is 64 KB, and the JavaScript stack limit is set to three quarters of it. A JS "stack overflow" or a FreeRTOS stack panic means raise it.
- PSRAM and flash speed. The defaults run octal PSRAM and flash at 120 MHz, an experimental
IDF feature that dual-core rendering needs. Flash above 80 MHz depends on the flash chip, so on
another board drop both to 80 MHz if it fails to boot. A quad-PSRAM board needs
CONFIG_SPIRAM_MODE_QUAD. - First boot pauses while the bytecode loader reads about 940 KB; that is not a hang.
- The config partition is found by its label
config; keep the label if you resize it, and size it at or above your.erpkg(2 MB by default). - The QuickJS pin. The tag the top-level
CMakeLists.txtfetches must match the one the bridge builds against and the version stamped into the.erpkg; a mismatch is rejected with a panel. Bump them together.
The Flow B build
The same board can run Flow B: no QuickJS, a fixed arena, and a compiled package in a flash slot. It has its own
sdkconfig layer, and builds main/main_flow_b.c in place of main.c:
cd examples/esp32/esp32-s3
idf.py -B build-flow-b -D SDKCONFIG=sdkconfig.flow-b \
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.flow-b" set-target esp32s3
idf.py -B build-flow-b -D SDKCONFIG=sdkconfig.flow-b \
-D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.flow-b" build flash monitor
With no package installed, the first boot shows the error screen, saying so. Then, from your project:
npx embedded-react build --aot # → dist/app.erpkg
npx embedded-react flash --board esp32-s3 --port PORT
partitions.csv has two package slots after the config
partition, erb_a and erb_b, 2 MB each, so the Flow A build's layout doesn't move. embedded-react flash writes
the one that isn't running, and the last good package stays until a new one passes verification.
The layer also makes a hang reset the board (CONFIG_ESP_TASK_WDT_PANIC). To check the error screen, add
-DER_BOARD_CRASH_TEST=1 to the build for a panic 5 seconds after boot, or =2 for a hang. Each shows the error
screen after the reset, naming the fault, then the app comes back and it happens again.
The thermostat demo picks its wide layout at 800×480.
WiFi
The example has an optional WiFi build: a station the app can scan with and join, the network and
time zone saved in encrypted NVS, and NTP keeping Date.now() on time. It is a build of its own,
layered on the defaults, because the WiFi stack needs internal RAM the board is short of:
idf.py -B build-wifi -D SDKCONFIG=sdkconfig.wifi -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.wifi" build flash
Pass the same arguments to every idf.py command for this build (… -p PORT monitor, … menuconfig);
plain idf.py keeps building the default firmware. The NTP server is CONFIG_ER_SNTP_SERVER
(menuconfig, under embedded-react: WiFi; default pool.ntp.org), and SNTP resyncs hourly and
after every reconnection.
In the thermostat demo the settings sheet gains a WI-FI page (scan, pick a network, type its password on the on-screen keyboard) and a TIME ZONE page, and the header clock sets itself.
How it reaches the app. main/network.c runs the station and SNTP on their own tasks and calls
er_runtime_set_wall_clock() on each sync, so Date.now() is UTC. main/network_js.c installs two
objects into the JavaScript context through ErRuntimeConfig.install_host_globals: __erWifi
(scan, networks, connect, forget, status) and __erClock (utcOffsetMs, timeZone,
setTimeZone). Where they are absent (the simulator, other boards) the app leaves the pages out.
This is the pattern for any host data a Flow A app needs: install an object, and let the app
feature-detect it.
What it costs, measured with the thermostat idle: about 36 KB of internal RAM once WiFi is up,
which the WiFi build makes room for by cutting the main task stack from 64 to 36 KB (an app that
recurses deeper than the thermostat gets a JavaScript "stack overflow"; raise
CONFIG_ESP_MAIN_TASK_STACK_SIZE at the cost of WiFi's margin); about 615 KB of flash; and no
measurable change in frame time.
The saved settings are encrypted, password included, with a key in eFuse. The first boot of the
WiFi build writes a random key into eFuse BLOCK_KEY5, and that is permanent. It is the block the
S3 cannot use for flash encryption, so nothing is lost, and the block is read-protected. If KEY5
already holds a different key, WiFi does not start and the log says why;
CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID picks another block. To keep the settings in plain text and leave
the eFuses alone, set CONFIG_NVS_ENCRYPTION=n in sdkconfig.defaults.wifi before the first flash.
Measuring
The example always draws a metrics panel in the bottom-right corner of the screen with this host's
own numbers: FPS, CPU load, free PSRAM, and internal RAM. Build with idf.py -DER_PERF_DETAIL=1 to
add the engine's frame instrumentation: last and worst frame times, the split between JavaScript, layout,
raster, and present, and the region the worst frame repainted. main.c closes each frame before the
pacing delay, so FRM is work time, the same basis as the CPU-load line.
FPS 48 host metrics
CPU 61%
PSRAM 3204K
IRAM 118K
FRM 18.4 PK 2013.1 frame time: last / worst, ms
J6.2 L0.3 R9.1 P2.4 last frame: JS, layout, raster, present
PK J1900 L12 R80 P9 the WORST frame's split: which subsystem to blame
PKDRT 800x40 32k the WORST frame's repainted region (pairs with PK)
VEC 3/8 IMG 5/32 vector and image slots in use
Performance explains how to read it, and Engine internals has the full line set and the region policies a host can choose between.