Architecture
The repository is a monorepo with one self-contained CMake or npm project per concern. The root
CMakeLists.txt builds nothing; it only hosts the repo-wide formatting targets.
engine/ Pure C99 runtime: scene graph, layout, rendering, text, animation.
Knows nothing about React. er_scene.h is a plain C ABI.
backends/ Hardware adapters, one folder per rendering API or peripheral. A backend
implements five function pointers; the rest of the stack is portable.
runtime/ The device-side pieces no JS engine owns: the ERCF container reader and
CRC, the asset-pack loader, the hot-reload frame parser.
bridges/ Frontends that drive the engine.
quickjs/ Flow A: the NativeUI C bridge and er_runtime host core over QuickJS.
packages/ The npm packages.
embedded-react/ The npm package `embedded-react`: components, reconciler, bundler,
asset bakers, and the dev/export/build CLI.
embedded-react/aot/ Flow B: the JSX-to-C ahead-of-time compiler.
create-embedded-react/ The `npm create embedded-react` scaffolder and its starter templates.
demos/ JSX demo apps (thermostat, watch-face) written against the public API.
Each compiles through both flows.
examples/ End-to-end host integrations: one engine + one backend + one flow,
packaged for a board (linux, linux-aot, esp32-s3, esp32-2432s028r,
rp2040-touch-lcd-1.69).
tools/ Developer tooling: the SDL simulator, the WebAssembly simulator build and
dev server, the release and version-sync scripts, the consumer smoke test.
website/ This site.
The boundary
One line runs through the whole design: the engine does not know who is calling it.
er_scene.h is a C API for creating nodes, setting props, arranging a tree, and committing a frame.
The engine never imports React, never sees JSX, never includes a platform header. That neutrality
is not a stylistic preference; it is what makes the two flows possible.
React on QuickJS JSX compiled to a package
(bridges/quickjs) (packages/embedded-react/aot)
│ │
└──────────────┬─────────────────────┘
▼
er_scene.h
│
engine/
│
EmbeddedRenderBackend
│
backends/<display>/
Flow A drives the engine from a JavaScript reconciler through the bridge; Flow B drives it from a verified package; both share one renderer, so a layout or rendering fix lands once. The same boundary leaves room for frontends that do not exist yet (a Lua UI, a JSON tree loader, a visual editor's output) without forking the engine. None of those is on the roadmap; the door is simply kept open.
That does not change what the project is. Embedded React is React Native for embedded MCUs; the engine's neutrality is an implementation choice that keeps the two flows honest with each other.
The layers, top down
The npm package (packages/embedded-react/src/) is what an app imports. embedded-react/ holds the
public surface: the component tags, StyleSheet, Animated, PanResponder, AppRegistry, the
hooks. Beside it, host-config.js is the react-reconciler host config that turns React's
createInstance/appendChild/commitUpdate into NativeUI.* calls, renderer.js creates the
root, and props.js flattens styles into the prop bag the bridge reads. The assets/ folder holds
the build-time bakers (images to premultiplied ARGB, fonts to bitmap glyphs, SVG to an op-tape) and
the container writer. cli.mjs and sim-server.mjs are the dev/export/build commands.
The QuickJS bridge (bridges/quickjs/) is C. native_ui_bridge.c publishes the NativeUI
object into a QuickJS context and forwards each method to the engine, interning prop names and
hashing prop bags so an unchanged setProps is cheap. er_runtime.c is the portable host core:
runtime and context lifecycle, the lite JavaScript profile, the host globals (console, screen,
timers, the persist store), container loading with the QuickJS version check, the frame pump, the
error screen for JavaScript errors. er_js_alloc.c is the allocator that reports real block sizes so QuickJS's collector
works on bare metal. NativeUI bridge documents the surface.
The runtime (runtime/) is C that links only the engine. er_container.c reads an ERCF
container: the magic, the format version, every section bound, and the CRC, before anything runs.
er_assets.c registers an ERPK pack. er_hotreload.c parses the USB reload frames. The bridge
links it, and the Flow B runtime will reuse it; Flow B packages is that
runtime's design.
The AOT compiler (packages/embedded-react/aot/) parses the app's JSX with Babel, folds
module-level constants (including the screen size), and emits a package: a tree of node templates, a
useState state machine in slots, handlers as bytecode functions, animations as engine calls, PanResponder
configs as responder bindings. It rejects anything it cannot lower, by message, at build time.
compile.mts is the entry point and CLI; each stage is a TypeScript module under compiler/, with
one file per typed element in compiler/elements/. The stages lower to a typed intermediate
representation (compiler/ir/), which compiler/package/ writes as a package. screenshot-smoke.mjs (npm run aot:smoke) builds the AOT desktop host, compiles each demo to a package, and
checks that one rendered frame has content.
The engine (engine/) is organized by what it does: scene/ (node pool, tree, props, dirty
tracking, render orchestration, hit-testing), layout/ (the Yoga-compatible solver), rendering/
(rounded rectangles, shadows, transforms, images, vectors, the arc widget), text/ (UTF-8, glyphs,
line layout), animation/ (values, curves, the native driver), font/ (the built-in fonts and the font
registry), and core/ (backend glue, the clock, the frame tick). include/er_scene.h and native_renderer.h are
the only headers downstream code includes. Engine and backends
and the C engine reference cover it from the outside;
Engine internals covers the inside.
The backends (backends/) each implement EmbeddedRenderBackend for one API: esp32-lcd
(RGB parallel, PSRAM framebuffer), esp32-spi-lcd (banded RGB565), pico-spi-lcd (RP2040, one
RGB565 framebuffer), dma2d (STM32 Chrom-ART), sdl (desktop), software and web (the browser
simulator). Writing a backend is the guide.
The examples (examples/) are the reference hosts, one per board and flow, and the place a
new board starts: copy the closest one and change board.c.
Where a frame's work happens
For a Flow A frame: the host polls touch and calls er_runtime_pump(). The pump delivers touches
and due timers to JavaScript inside one batch, React renders and diffs, the host config marshals
changes through NativeUI.setProps and friends, and the batch closes with one er_commit(), which
lays out what moved, computes damage, and paints each damaged rectangle through the backend. The
host presents the dirty region and advances the clock with embedded_renderer_tick(), which also
steps every native animation. Rendering pipeline follows the
commit in detail; the Performance guide shows how to measure each phase.
For a Flow B frame, the JavaScript half does not exist: an event handler is package bytecode that writes
state and props, and the host's er_commit() does the rest.
Building the pieces
Each folder is its own project, and the runtime, the bridge, and the examples pull the engine in
themselves with a relative add_subdirectory; nothing is installed.
| Piece | Command | Notes |
|---|---|---|
| Engine and its tests | cmake -S engine -B build -DBUILD_TESTING=ON && cmake --build build | A static library, embedded-react; ctest --test-dir build runs the suites |
| Runtime and its tests | cmake -S runtime -B runtime/build && cmake --build runtime/build | A static library, er-runtime; ctest --test-dir runtime/build runs the container test |
| QuickJS bridge | cmake -S bridges/quickjs -B bridges/quickjs/build && cmake --build bridges/quickjs/build | Also builds er-bridge-quickjs-runtest (the headless test harness) and er-bridge-quickjs-compile (the bytecode precompiler npm run pack needs) |
| The npm package | cd packages/embedded-react && npm install | npm run pack -- <demo>, npm run aot -- <demo>, npm run sim -- <demo>, npm test, npm run parity; the -- <demo> names a folder in demos/ |
| Desktop hosts | cmake -S examples/linux -B examples/linux/build (and examples/linux-aot) | Linux |
| SDL simulator | cmake -S tools/simulator -B tools/simulator/build && cmake --build tools/simulator/build | Then npm run sim -- <demo> from the npm package launches it with a watch loop |
| WebAssembly simulator | node tools/web-sim/build.mjs | Needs the Emscripten SDK (emcc on PATH); writes the wasm and stages it into the package's sim/. node tools/web-sim/dev.mjs [demo] is the repo dev loop |
| Board examples | idf.py build flash, the Pico SDK's CMake | One guide per board |
| This site | cd website && npm install && npm start | npm run build for the static site; the changelog and roadmap pages are generated from the repo-root files on every start and build |
The bytecode precompiler is not the Flow B compiler: it turns a Flow A bundle into QuickJS bytecode
that the VM still runs, while the Flow B compiler in packages/embedded-react/aot/ emits a package and
drops QuickJS entirely. The WebAssembly module is built by CI and shipped inside the npm package, so only
someone changing the engine or the bridge needs Emscripten.