Skip to main content

Problem

The Hiro League Flutter app is open source. Community contributors build it for all platforms (Windows, macOS, Android, iOS, web) via GitHub Actions. The app includes a character face renderer that displays animated dot-matrix faces driven by text expressions. The core IP is the expression-to-visual algorithm — the logic that interprets text (e.g. "happy", "surprised") and produces an animation plan: which dots move, how far, with what easing and timing. This logic must remain proprietary even though the app repo is public. The rendering layer itself (drawing dots on screen) is standard Flutter and is fine to be open source.

Constraints

  • The app is open source; contributors must be able to build without accessing the algorithm source
  • All five platforms must be supported from a single codebase
  • The face occupies roughly 1/3 of the screen at ~9×9 real pixels per dot (~40–70 dots wide, ~40–89 dots tall)
  • Style is dot-matrix; resolution is intentionally low
  • Target framerate: 24 fps

Locked decisions

#Decision
1The expression-to-animation algorithm lives in a native library (C or Rust), not in Dart.
2The native library is compiled per-platform: .dll/.so/.dylib/.xcframework for native targets, .wasm for Flutter web.
3The native source is in a private repository. Only compiled binaries are committed to the public app repo.
4The Dart bridge uses dart:ffi on native platforms and JS interop on web, selected via conditional imports.
5The Flutter renderer is pure Dart (CustomPainter) and is fully open source.
6The native library is called once per expression change, not once per frame. It returns an AnimationDescriptor. Flutter’s own animation system handles per-frame interpolation.
7flutter_rust_bridge is the preferred option if Rust is chosen (auto-generates Dart FFI bindings from Rust). ffigen is the fallback if C is chosen.

Architecture

Expression text input


┌─────────────────────────────────────────────────────┐
│  Native library  (C or Rust — private source)        │
│  Compiled once, multiple targets:                    │
│  • .dll / .so / .dylib / .xcframework  (native)      │
│  • .wasm                               (web)         │
└──────────────┬──────────────────────────────────────┘
               │  AnimationDescriptor
               │  (keyframes, dot states, easing, duration)

┌─────────────────────────────────────────────────────┐
│  Dart bridge  (written once, open source)            │
│  • dart:ffi         on native platforms              │
│  • dart:js_interop  on Flutter web                   │
│  Selected at compile time via conditional imports    │
└──────────────┬──────────────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│  Flutter renderer  (Dart / CustomPainter)            │
│  Reads AnimationDescriptor, interpolates dot states, │
│  paints the dot grid at device framerate.            │
│  Identical on all platforms including web.           │
└─────────────────────────────────────────────────────┘

Cross-platform compilation targets

PlatformBinary formatBridge
Android.so (arm64, arm, x86_64)dart:ffi
iOS.xcframeworkdart:ffi
macOS.dylibdart:ffi
Windows.dlldart:ffi
Linux.sodart:ffi
Flutter web.wasmdart:js_interop
The algorithm source is written once. The CI pipeline cross-compiles it for each target. Only the compiled artifacts are committed to the public app repo.

Conditional import pattern

// face_engine.dart — abstract interface (open source)
abstract class FaceEngine {
  AnimationDescriptor generateExpression(String expression);
}

// face_engine_stub.dart — no-op, used when neither ffi nor js_interop is available
class FaceEngineStub implements FaceEngine {
  AnimationDescriptor generateExpression(String expression) =>
      AnimationDescriptor.placeholder();
}
// Conditional import selects the right implementation at compile time
import 'face_engine_stub.dart'
    if (dart.library.ffi) 'face_engine_native.dart'
    if (dart.library.js_interop) 'face_engine_web.dart';
The stub allows contributors to build and run the app locally without the compiled native library — the face renders a placeholder.

AnimationDescriptor

The native library returns a structured descriptor, not raw pixel data. Flutter drives the animation from this descriptor using its own interpolation.
class AnimationDescriptor {
  final Duration duration;
  final List<DotKeyframe> keyframes; // per-dot: position, opacity, scale over time
  final Curve easing;
}
This means:
  • FFI is only called on expression transitions — not in the render loop
  • Flutter renders at whatever framerate the device supports (not capped at 24 fps)
  • The algorithm’s output is data, not pixels — harder to reverse-engineer the intent

Performance notes

  • Dot grid at 1/3 screen with 9×9 grouping: ~3,000–5,000 dots
  • FFI call overhead: ~1–5 µs per call; frame budget at 24 fps is ~41 ms (overhead is ~0.01%)
  • If pixel buffer transfer is needed: 64×64 RGBA = 16 KB/frame, 384 KB/s — trivial
  • Zero-copy sharing is possible: allocate a shared calloc buffer once; native writes, Dart reads via asTypedList
Per-frame FFI calls are not needed and should be avoided. The native library is called once per expression change. The AnimationDescriptor drives all subsequent frames entirely in Dart/Flutter.

Repository layout

hiroleague-app/            (public open-source repo)
├── lib/
│   └── features/bots/face/
│       ├── face_engine.dart          # abstract interface
│       ├── face_engine_stub.dart     # placeholder for local dev
│       ├── face_engine_native.dart   # dart:ffi bridge
│       ├── face_engine_web.dart      # JS interop bridge
│       └── face_renderer.dart        # CustomPainter — renders the dot grid

└── native/
    └── face_engine/
        ├── libface_engine.dll        # compiled Windows binary
        ├── libface_engine.so         # compiled Android/Linux binary
        ├── libface_engine.dylib      # compiled macOS binary
        ├── FaceEngine.xcframework/   # compiled iOS binary
        └── face_engine.wasm          # compiled web binary

hiroleague-face-engine/    (private repo — algorithm source only)
├── src/
│   └── ...                # C or Rust source
└── build/                 # CI pipeline cross-compiles and pushes binaries to app repo

Build flow for contributors

  1. Contributor clones the public app repo
  2. native/face_engine/ contains pre-compiled binaries — no source
  3. Local dev builds use the stub (face_engine_stub.dart) — face shows a placeholder
  4. CI (GitHub Actions) has a deploy key for the private engine repo; full builds use real binaries
  5. Release builds are produced only in CI — contributors never need the engine source

Open questions

Both work. Rust is preferred because:
  • flutter_rust_bridge auto-generates the Dart FFI bindings — no manual binding work
  • Memory safety without a garbage collector
  • Cross-compilation to all targets including WASM is well-supported via cargo
C is simpler if the algorithm is already prototyped in C, and ffigen handles binding generation.
Fragment shaders in Flutter are GLSL text files — readable source. The rendering aesthetic (dot-matrix look, bloom, scanlines) can be achieved with shaders, but the shader source would be visible in the public repo.Since the rendering layer is intentionally open, this is acceptable. The IP lives in the AnimationDescriptor that feeds the renderer, not in the shaders themselves.If rendering shaders also need to be private, they can be compiled to SPIR-V and embedded in the native binary, then passed to Flutter via a texture. This adds complexity and is deferred until needed.
Flutter web (CanvasKit/Skia) has partial fragment shader support. For the dot-matrix renderer, CustomPainter with canvas drawing operations (circles, rounded rectangles) is more portable than shaders and avoids web shader compatibility issues.