Skip to main content

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.

PieceCommandNotes
Engine and its testscmake -S engine -B build -DBUILD_TESTING=ON && cmake --build buildA static library, embedded-react; ctest --test-dir build runs the suites
Runtime and its testscmake -S runtime -B runtime/build && cmake --build runtime/buildA static library, er-runtime; ctest --test-dir runtime/build runs the container test
QuickJS bridgecmake -S bridges/quickjs -B bridges/quickjs/build && cmake --build bridges/quickjs/buildAlso builds er-bridge-quickjs-runtest (the headless test harness) and er-bridge-quickjs-compile (the bytecode precompiler npm run pack needs)
The npm packagecd packages/embedded-react && npm installnpm run pack -- <demo>, npm run aot -- <demo>, npm run sim -- <demo>, npm test, npm run parity; the -- <demo> names a folder in demos/
Desktop hostscmake -S examples/linux -B examples/linux/build (and examples/linux-aot)Linux
SDL simulatorcmake -S tools/simulator -B tools/simulator/build && cmake --build tools/simulator/buildThen npm run sim -- <demo> from the npm package launches it with a watch loop
WebAssembly simulatornode tools/web-sim/build.mjsNeeds 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 examplesidf.py build flash, the Pico SDK's CMakeOne guide per board
This sitecd website && npm install && npm startnpm 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.