Skip to content

Architecture ​

Philosophy ​

Reincarnate performs full recompilation, not interpretation. Source bytecode and scripts are decompiled into a typed intermediate representation, optimized, and compiled to native targets. The goal is accurate reproduction — preserve fidelity, don't redesign. When rendering can't be fully lifted, overlay a modern UI layer over the original rather than patching internal rendering.

Pipeline ​

Source binary/scripts
        │
        ▼
   ┌──────────┐
   │ Frontend  │   (per-engine: Flash, GameMaker, Twine, etc.)
   └────┬─────┘
        │  untyped IR + frontend_passes
        ▼
   ┌──────────┐
   │ Transform │   (type inference, optimization, coroutine lowering, etc.)
   │ Pipeline  │
   └────┬─────┘
        │  optimized typed IR
        ▼
   ┌──────────┐
   │ Backend   │   (TypeScript; Rust planned)
   └──────────┘

Frontends parse engine-specific formats and emit untyped IR plus a list of engine-specific PureIrPass instances (frontend_passes). The transform pipeline runs type inference and optimization. Backends emit target code.

Transform pipeline passes ​

The pipeline is declarative: passes declare requires() and invalidates() dependencies; the pipeline topo-sorts them and inserts re-runs automatically when a pass invalidates an earlier one. The current standard passes in registration order (actual execution order is determined by dependency sort):

PassDescriptionFlags
constructor-struct-inferInfers struct shapes from constructor SetField patterns—
constraint-solve-hmHM unifier — resolves TypeVars from constraints; runs twice (before and after mem2reg via invalidation)—
builtin-overload-selectPost-HM: resolves typed builtin variants (e.g. add_f64 vs add_str)run_once
inlineInlines functions with InlineHint::Always (stdlib bodies)run_once
constant-foldingAlgebraic simplification; runs twice (before and after mem2reg)—
cfg-simplifyRemoves unreachable blocks—
coroutine-loweringLowers async/yield to state machines—
mem2regPromotes Alloc/Store/Load chains to SSA valuesinvalidates: constraint-solve-hm, constant-folding
redundant-cast-eliminationRemoves identity and nested casts—
dead-code-eliminationRemoves dead instructions and unreachable blocks—

After the standard pipeline, frontend_passes run in order. These are engine-specific IR normalizations (e.g. GML logical-op pattern restoration) provided by the frontend as PureIrPass instances.

RuntimeRegistry ​

Stdlib and builtin functions are registered in the Module::runtime_registry as real FuncIds with typed signatures. This means:

  • The inliner can substitute IR bodies for stdlib functions at call sites
  • BuiltinOverloadSelect can resolve the correct typed variant after HM inference
  • Backends emit direct typed calls rather than dynamic dispatch through Op::SystemCall

Supported Engines ​

EngineFormatStrategy
Flash (AVM2)ABC bytecodeFull recompilation (first target)
Ren'PyRPY scripts / RPYC bytecodeFull recompilation
RPG Maker VX AceRuby/RGSS scriptsFull recompilation
RPG Maker MV/MZJSON event scripts + JS engineFull recompilation
GameMakerGML bytecodeFull recompilation
Director/ShockwaveLingo scriptsFull recompilation
Twine (SugarCube)SugarCube macro DSL in HTMLFull recompilation
Twine (Harlowe)Harlowe hook/macro syntax in HTMLFull recompilation
InformStory formatsFull recompilation
VB6P-CodeFull recompilation
Java AppletsJVM bytecodeFull recompilation
Silverlight.NET ILFull recompilation
HyperCard / ToolBookStack formatsFull recompilation

RPG Maker MV/MZ ships a JavaScript engine, but Reincarnate still performs full recompilation to preserve fidelity and enable cross-target optimization.

Twine story format coverage ​

The Twine frontend targets SugarCube and Harlowe only. Two other story formats exist but are not supported:

  • Snowman — Not a scripting language. Passages contain raw JavaScript with <% %> template tags and jQuery. There is no macro DSL to parse — it's already JavaScript. Lifting it would be pointless: the source is the target. Very few published games use it.

  • Chapbook — Minimal adoption. Uses a unique "inserts + modifiers" syntax with no meaningful ecosystem. Community forum threads are mostly people looking for example games, which speaks to the format's obscurity. Not worth the parser investment until there's real demand.

SugarCube and Harlowe cover the overwhelming majority of Twine games in the wild. SugarCube dominates large/complex projects; Harlowe is the Twine 2 default and is common for shorter works. Harlowe is arguably the higher-value target — its runtime is slower, its save system is barely functional, and its syntax actively resists extension and debugging.

Library Replacement (HLE) ​

Reincarnate works like a console recompiler: user logic is faithfully translated, but original runtime libraries are detected and replaced with native equivalents. This is the HLE (High-Level Emulation) approach — instead of emulating the original runtime instruction-by-instruction, we recognize API boundaries and swap in modern implementations.

┌─────────────────────────────────────────────────────┐
│                   Original binary                    │
│  ┌──────────────┐  ┌─────────────────────────────┐  │
│  │  User logic   │  │  Runtime libraries           │  │
│  │  (game code)  │  │  (flash.display, flash.text) │  │
│  └──────┬───────┘  └──────────────┬──────────────┘  │
│         │                         │                  │
└─────────┼─────────────────────────┼──────────────────┘
          │                         │
          ▼                         ▼
   Recompiled to IR          Detected at boundary,
   → transforms              replaced with native
   → codegen                 implementation
          │                         │
          ▼                         ▼
   ┌──────────────┐  ┌─────────────────────────────┐
   │  Translated   │  │  Replacement runtime         │
   │  user code    │──│  (canvas renderer, DOM text,  │
   │  (.ts / .rs)  │  │   Web Audio, etc.)           │
   └──────────────┘  └─────────────────────────────┘

Why this matters ​

Every legacy runtime has a standard library: Flash has flash.display, flash.events, flash.text; Director has Lingo built-ins; VB6 has COM controls and the VB runtime. These libraries define the app's interaction with rendering, input, persistence, etc.

The recompiler's job is two-fold:

  1. Translate user logic — faithful recompilation of the app's own code
  2. Replace runtime libraries — swap the original runtime with a modern implementation that provides the same API surface

This separation is what makes the output actually run. Translating MovieClip.gotoAndStop(3) to TypeScript is useless without a MovieClip class that does the right thing. The replacement runtime IS the optimization.

Library boundary detection ​

The frontend is responsible for identifying library boundaries. For Flash, the flash.*:: namespace cleanly separates stdlib from user code. The frontend marks these references in the IR so downstream passes know which calls cross the boundary.

What the frontend produces:

IR instruction:   GetField "flash.text::TextFormatAlign"
IR metadata:      external_lib = "flash.text", short_name = "TextFormatAlign"

The backend consumes the metadata to emit imports. It never parses namespace strings — that's the frontend's domain knowledge.

Three-layer runtime architecture ​

The replacement runtime has three distinct layers:

Emitted user code (.ts / .rs)
        │
        │  imports Flash API classes
        ▼
┌──────────────────────────────────────────────┐
│  API Shim Layer (engine-specific)            │
│  MovieClip, EventDispatcher, Loader, ...     │
│  Implements original engine semantics        │
│  flash/display.ts, flash/events.ts, ...      │
│                                              │
│  Owned by: engine × target language          │
│  (runtime/flash/ts/, runtime/flash/rs/)      │
└────────────────┬─────────────────────────────┘
                 │
                 │  calls platform abstraction
                 ▼
┌──────────────────────────────────────────────┐
│  Platform Interface                          │
│  draw_image(), fill_rect(), play_sound()     │
│  load_bytes(), save_persistent(), ...        │
│                                              │
│  Rust: generic traits (monomorphized)        │
│  TS: module re-exports (tree-shaken)         │
└────────────────┬─────────────────────────────┘
                 │
                 │  implemented by
                 ▼
┌──────────────────────────────────────────────┐
│  Platform Implementation (per-target)        │
│  BrowserCanvas2D, WebAudio, fetch/localStorage│
│  WgpuRenderer, CpalAudio, winit input        │
│  NullPlatform (testing)                      │
│                                              │
│  Selected at build time                      │
└──────────────────────────────────────────────┘

Layer 1: API Shim — Implements the original engine's class hierarchy and semantics in the target language. Flash's MovieClip has frame timelines, display list children, and event dispatching. Director's Sprite has cast members and score-based animation. These are completely different APIs but both call the same platform layer to draw, play sounds, and handle input. The API shim is engine-specific but platform-agnostic.

Layer 2: Platform Interface — A thin abstraction over platform capabilities: 2D rendering, audio playback, input polling, network I/O, persistence. This is the boundary where swapping happens. The interface is engine-agnostic — Flash and Director both call draw_image() to put pixels on screen. In Rust, these are generic traits that monomorphize (zero virtual dispatch). In TypeScript, these are module-level function re-exports that the bundler resolves at build time (zero indirection — the unused implementation is tree-shaken away entirely).

Layer 3: Platform Implementation — Concrete implementations of the platform interface for each deployment target. Browser: Canvas 2D + Web Audio + fetch + localStorage. Desktop: wgpu + cpal + winit + filesystem. Testing: null platform that no-ops all I/O.

Why three layers ​

The naive approach (API shim directly calls browser APIs) is what the current runtime does. This breaks down when:

  • Multiple deployment targets — The same Flash game should run in a browser (Canvas 2D) and as a native desktop app (wgpu). Without the platform interface, you'd need separate API shim implementations per target, duplicating all the engine logic.
  • Testing — You can't unit-test a display list implementation that calls ctx.fillRect() without a browser. With the platform interface, inject a null platform and test pure logic.
  • Progressive optimization — Swap BrowserCanvas2D for BrowserWebGL or BrowserWebGPU without touching the API shim. Swap the full-fidelity flash.events (capture/target/bubble) for a flat-dispatch version. Each layer swaps independently.

Swappable runtime packages ​

Each engine's API shim can have multiple implementations at different fidelity levels:

PackageStub (correctness)Full (faithful)Optimized (native)
flash.displayJS class hierarchyFull display listCanvas2D direct draw, skip display list
flash.textDOM <div> measurementTextField with format runsPre-measured glyph atlas
flash.eventsSimple listener mapFull capture/bubbleFlat listener dispatch
flash.netlocalStorage wrapperSharedObject + URLLoaderIndexedDB with sync API

A project starts with stub implementations and progressively replaces individual packages with optimized versions — same user code, better runtime. The platform interface ensures implementations only change what's below the boundary.

Runtime ownership ​

The replacement runtime is neither frontend nor backend. It's a third concern:

  • The frontend extracts bytecode and emits IR. It identifies library boundaries (flash.*:: namespaces) and attaches metadata. It does not implement any runtime classes.
  • The backend emits target code and wires up imports to the runtime package. It does not know what MovieClip does — it just knows the import path.
  • The runtime implements engine semantics in the target language, calling the platform interface for I/O. It is standalone — usable independently of the recompiler pipeline.

File layout:

runtime/
  flash/
    ts/                    ← Flash replacement runtime (TypeScript)
      flash/
        display.ts         ← MovieClip, Stage, DisplayObject, ...
        events.ts          ← EventDispatcher, Event subclasses
        text.ts            ← TextField, TextFormat, ...
        net.ts             ← URLLoader, SharedObject, ...
        utils.ts           ← ByteArray, Timer, Dictionary, ...
      platform/
        interface.ts       ← Platform interface (exported functions)
        browser.ts         ← Browser implementation (Canvas 2D, fetch, ...)
        null.ts            ← Null implementation (testing)
    rs/                    ← Flash replacement runtime (Rust, future)
      src/
        display.rs
        events.rs
        ...

Pipeline with library replacement ​

Source binary
      │
      ▼
┌──────────┐
│ Frontend  │ → IR + library boundary metadata
└────┬─────┘
     │
     ▼
┌──────────┐
│ Transforms│ → optimized IR (user logic only; library calls untouched)
└────┬─────┘
     │
     ▼
┌──────────┐     ┌───────────────────┐
│ Backend   │ ←── │ Replacement runtime │  (selected per engine × target)
└────┬─────┘     └───────────────────┘
     │
     ▼
  Output (.ts/.rs) + runtime package

The backend's job: emit user code + wire up imports to the replacement runtime. The frontend's job: detect library boundaries and attach metadata. The runtime's job: make MovieClip.gotoAndStop(3) actually work.

Codegen Backends ​

Rust Source (primary) ​

Emits .rs files that compile with rustc. Benefits:

  • Monomorphization eliminates all generic overhead
  • Const evaluation resolves static data at compile time
  • LLVM optimizations apply automatically
  • Native desktop and mobile via standard Rust targets
  • WASM via rustc --target wasm32-unknown-unknown

Desktop is truly native — no Tauri, no WebView, no embedded browser. The generated Rust code links against wgpu + winit for rendering and windowing.

TypeScript (secondary) ​

Emits .ts files for web deployment. Useful when:

  • The target is a browser game/app
  • WASM binary size is a concern
  • Integration with existing web infrastructure is needed

Type Inference ​

Hindley-Milner constraint-based type recovery. Source languages (ActionScript 3, Lingo, GML, etc.) are often dynamically typed — inference recovers concrete types where possible.

When inference fails, the type becomes Unknown. Unknown is an inference failure, not a legitimate runtime type — the goal is to eliminate it. Most well-typed ActionScript 3 code should infer fully.

Key properties:

  • Constraint-based: unification over type variables via an HM solver (ConstraintSolveHM)
  • Struct inference: ConstructorStructInfer recovers object shapes from constructor SetField patterns
  • Builtin overloads: BuiltinOverloadSelect resolves typed arithmetic variants post-HM (e.g. add_f64 vs add_str)
  • Inlining: stdlib functions with attached IR bodies are inlined at call sites (InlineHint::Always)
  • Per-function: inference runs per function, cross-function via signatures

Platform Interface ​

The platform interface is the boundary between engine-specific logic (display lists, event systems, animation timelines) and target-specific I/O (canvas drawing, audio playback, keyboard input, file access). It must be:

  • Zero-cost — In Rust, generic traits monomorphize to direct calls. In TypeScript, module-level function re-exports resolve at build time (the bundler tree-shakes the unused implementation).
  • Swappable — Different implementations for different deployment targets. The same Flash runtime runs on a browser (Canvas 2D + Web Audio) or native desktop (wgpu + cpal) by swapping the platform implementation.
  • Low-level — The interface provides 2D drawing primitives, not engine concepts. It has draw_image() and fill_rect(), not add_to_display_list(). Engine semantics (display lists, event bubbling, frame timelines) live in the API shim layer above, not here.

Platform capabilities ​

The platform interface is a cross-language contract — TypeScript, Rust, C#/Unity, SDL all implement the same conceptual interface. Names below are the canonical snake_case form; TypeScript implementations camelCase them. API surfaces use only primitive types (int, float, bool, string, opaque handles) — no language-specific types (AudioBuffer, HTMLImageElement) in exported signatures. Some functions are optional or have platform-specific implementations; unsupported calls must fail explicitly rather than silently no-op.

Capability query ​

Platforms expose a capability query so shims can branch without hardcoding target checks. Capability names are dot-scoped strings (concern.feature). Returns false for unknown names.

FunctionSignatureNotes
has_capability(name: str) → boolExample names: "graphics.pointer_lock", "clipboard.read", "clipboard.write", "window.fullscreen"

Example usage in a shim:

typescript
if (has_capability("graphics.pointer_lock")) {
    request_pointer_lock();
}

Graphics (2D) ​

Opaque handle types (u32, defined in shared types module): CanvasHandle, FontHandle, PathHandle, GradientHandle

All hot-tier draw operations take an explicit CanvasHandle — there is no implicit "active canvas" state. Multiple canvases can be drawn to in the same frame without set/restore ceremony.

Colors are RGBA packed as 0xRRGGBBAA. Matches WebGL convention, maps directly to GPU formats with no swizzling. Engines using other packing (e.g. Flash ARGB) convert in the shim.

Per-draw effect state (set_alpha, set_blend_mode, set_color_transform, set_image_smoothing) lives on the canvas state stack — saved and restored with save_state/restore_state. Not parameters on draw calls (avoids draw_image becoming a bag of effects). set_transform follows the same pattern.

Text alignment and baseline are parameters on draw_text, not state — they are intrinsic to where text lands, not an effect layered on top.

Named constant types (closed sets — Rust enum, TS const union, C# enum):

  • BlendMode: normal | additive | multiply | screen | erase
  • TextAlign: left | center | right
  • TextBaseline: top | middle | bottom | alphabetic
  • LineCap: butt | round | square
  • LineJoin: miter | round | bevel

Note: Graphics 3D uses Blend (not BlendMode) since the valid set differs: none | alpha | additive | premultiplied.

Setup tier:

FunctionSignatureNotes
init_surface(id: str) → CanvasHandleBind to an existing render surface by ID/handle (DOM canvas id on web; window/surface id on native)
create_canvas(w, h: int) → CanvasHandleCreate offscreen canvas
resize_canvas(canvas: CanvasHandle, w, h: int) → void
load_font(url: str) → FontHandle (async)Load and register a font
create_path() → PathHandleBegin recording a reusable path
path_move_to(path: PathHandle, x, y: float) → void
path_line_to(path: PathHandle, x, y: float) → void
path_bezier_to(path: PathHandle, cp1x, cp1y, cp2x, cp2y, x, y: float) → voidCubic bezier
path_quadratic_to(path: PathHandle, cpx, cpy, x, y: float) → voidQuadratic bezier
path_arc(path: PathHandle, x, y, r, start, end: float, ccw: bool) → void
path_close(path: PathHandle) → void
destroy_path(path: PathHandle) → voidFree path resources
destroy_canvas(canvas: CanvasHandle) → voidFree canvas resources
destroy_font(font: FontHandle) → voidFree font resources
create_linear_gradient(x0, y0, x1, y1: float) → GradientHandleLinear gradient along the line (x0,y0)→(x1,y1)
create_radial_gradient(x0, y0, r0, x1, y1, r1: float) → GradientHandleRadial gradient from inner circle to outer circle
gradient_add_stop(gradient: GradientHandle, offset: float, color: int) → voidAdd a color stop; offset is 0..1
destroy_gradient(gradient: GradientHandle) → void

Query (sync):

FunctionSignatureNotes
canvas_width(canvas: CanvasHandle) → int
canvas_height(canvas: CanvasHandle) → int
read_canvas_pixels(canvas: CanvasHandle, x, y, w, h: int) → bytesRGBA8 pixel readback; throws if unsupported by backend
canvas_to_image(canvas: CanvasHandle) → ImageHandleSnapshot canvas content into an ImageHandle; the image is a copy (further canvas changes don't affect it)

Hot tier — state (all saved/restored with save_state/restore_state):

FunctionSignatureNotes
set_transform(canvas: CanvasHandle, a,b,c,d,e,f: float) → voidSet CTM (6-element affine)
set_alpha(canvas: CanvasHandle, alpha: float) → voidGlobal opacity multiplier (0..1)
set_blend_mode(canvas: CanvasHandle, mode: BlendMode) → void
set_color_transform(canvas: CanvasHandle, matrix: float[20]) → void4×5 RGBA multiply+add matrix. Implementation note: Canvas 2D has no native color transform. Implement via an LRU cache of color-transformed images (see reference implementation in ~/git/bounty).
set_image_smoothing(canvas: CanvasHandle, enabled: bool) → voidfalse = nearest-neighbor (pixel art)
save_state(canvas: CanvasHandle) → voidPush transform + clip + effect state
restore_state(canvas: CanvasHandle) → voidPop state
reset_canvas_state(canvas: CanvasHandle) → voidReset transform, clip, and all effect state to defaults; does not clear pixel content
set_stroke_style(canvas: CanvasHandle, cap: LineCap, join: LineJoin, miter_limit: float) → voidStroke cap and join style; miter_limit only applies to miter join
set_dash_pattern(canvas: CanvasHandle, segments: float[], offset: float) → voidDash/gap lengths in pixels; empty array = solid; offset shifts the pattern start

Hot tier — drawing:

FunctionSignatureNotes
clear_canvas(canvas: CanvasHandle, color: int) → voidFill entire canvas with color; respects current clip; does not modify state stack
fill_rect(canvas: CanvasHandle, x,y,w,h: float, color: int) → voidRGBA 0xRRGGBBAA
draw_image(canvas: CanvasHandle, img: ImageHandle, sx,sy,sw,sh, dx,dy,dw,dh: float) → voidSource and dest rects
draw_canvas(dst: CanvasHandle, src: CanvasHandle, sx,sy,sw,sh, dx,dy,dw,dh: float) → voidComposite offscreen canvas onto another
draw_text(canvas: CanvasHandle, text: str, x,y: float, font: FontHandle, size: float, color: int, align: TextAlign, baseline: TextBaseline) → void
measure_text(text: str, font: FontHandle, size: float) → floatText width; no canvas needed

Hot tier — one-off paths (canvas-local, not reusable):

FunctionSignatureNotes
begin_path(canvas: CanvasHandle) → voidStart a new path
move_to(canvas: CanvasHandle, x, y: float) → void
line_to(canvas: CanvasHandle, x, y: float) → void
bezier_to(canvas: CanvasHandle, cp1x, cp1y, cp2x, cp2y, x, y: float) → void
quadratic_to(canvas: CanvasHandle, cpx, cpy, x, y: float) → void
arc(canvas: CanvasHandle, x, y, r, start, end: float, ccw: bool) → void
close_path(canvas: CanvasHandle) → void
fill_path(canvas: CanvasHandle, color: int) → voidFill current path
fill_path_gradient(canvas: CanvasHandle, gradient: GradientHandle) → voidFill current path with gradient
stroke_path(canvas: CanvasHandle, color: int, width: float) → voidStroke current path
clip(canvas: CanvasHandle) → voidClip to current path
begin_text_path(canvas: CanvasHandle, text: str, x, y: float, font: FontHandle, size: float) → voidAdd glyph outlines to current path; then fill/stroke/clip as normal

Hot tier — reusable paths:

FunctionSignatureNotes
fill_path_handle(canvas: CanvasHandle, path: PathHandle, color: int) → void
fill_path_handle_gradient(canvas: CanvasHandle, path: PathHandle, gradient: GradientHandle) → void
stroke_path_handle(canvas: CanvasHandle, path: PathHandle, color: int, width: float) → void
clip_path_handle(canvas: CanvasHandle, path: PathHandle) → void

Graphics 3D ​

A separate platform concern from graphics (2D). A 2D-only engine pays nothing for 3D init; a headless test renderer can omit it entirely. Never import from or into the 2D graphics concern.

Named constant types (closed sets — Rust enum, TS const union, C# enum):

  • LoadOp: clear | load
  • StoreOp: store | discard
  • Primitive: triangles | lines | points
  • Blend: none | alpha | additive | premultiplied (distinct from 2D BlendMode — different valid set)
  • CullMode: none | back | front

Opaque handle types (u32, cross-language safe):

  • GpuHandle — a 3D rendering context (one per game instance; surface binding is implementation-defined)
  • ShaderHandle — compiled shader program (identified by name; impl provides source)
  • LayoutHandle — interned vertex layout descriptor
  • MeshHandle — uploaded vertex + index buffer pair
  • TextureHandle — GPU texture
  • RenderTargetHandle — framebuffer / render texture (0 = main framebuffer)
  • UniformHandle — resolved uniform location (name resolved at setup time; used on hot path)

Design decisions:

  • Uniforms: named string API at the call site; implementation resolves name→(block, offset) at shader compile time via reflection and batches into a UBO. Callers never see binding indices.
  • Uniform lifetime: persistent per shader object — set once, reused across draws until changed.
  • Shader creation: sync ID, async compile. create_shader returns immediately; compilation happens in the background. Poll shader_ready. Draw calls against an unready shader are silently no-oped — the engine is responsible for not drawing until ready. Supports streaming (shaders not known at init time).
  • Vertex layout: interned via create_vertex_layout(format: str) → LayoutHandle. Format string parsed once; LayoutHandle used everywhere else. Format: attribute letter + component count, e.g. "p3n3uv2" = position(3) + normal(3) + uv(2).
  • Render passes: explicit begin_pass/end_pass with load_op/store_op declared upfront — required for tile-based GPU efficiency on mobile. Implicit set_render_target silently assumes load+store, which wastes bandwidth on tile GPUs.
  • Context handle: GpuHandle is returned by init_gfx3d(canvas). Each canvas has its own independent 3D context; multiple canvases → multiple GpuHandles. On browsers this maps to canvas.getContext("webgl2"); on native targets the canvas handle identifies the surface.

Setup tier:

FunctionSignatureNotes
init_gfx3d(canvas: CanvasHandle) → GpuHandleCreate a 3D context bound to the given canvas; one context per canvas, arbitrary many
create_shader(gpu: GpuHandle, name: str) → ShaderHandleSync ID; async compile in background
shader_ready(gpu: GpuHandle, id: ShaderHandle) → boolPoll until true before drawing
get_uniform(gpu: GpuHandle, shader: ShaderHandle, name: str) → UniformHandleResolve name at setup time; hot-path uses the returned handle
create_vertex_layout(gpu: GpuHandle, format: str) → LayoutHandleIntern once; use ID everywhere
create_mesh(gpu: GpuHandle, layout: LayoutHandle, vertices: float[], indices: int[]) → MeshHandleUpload geometry
update_mesh(gpu: GpuHandle, id: MeshHandle, vertices: float[]) → voidDynamic geometry update
destroy_mesh(gpu: GpuHandle, id: MeshHandle) → void
upload_image(gpu: GpuHandle, img: ImageHandle) → TextureHandleUpload a CPU-side image to GPU texture
create_texture(gpu: GpuHandle, w: int, h: int, data: int[]) → TextureHandleRGBA8 pixels
destroy_texture(gpu: GpuHandle, id: TextureHandle) → void
create_render_target(gpu: GpuHandle, w: int, h: int) → RenderTargetHandle
destroy_render_target(gpu: GpuHandle, rt: RenderTargetHandle) → void
render_target_texture(gpu: GpuHandle, id: RenderTargetHandle) → TextureHandleRead result as texture
destroy_shader(gpu: GpuHandle, shader: ShaderHandle) → void

Hot tier:

FunctionSignatureNotes
begin_pass(gpu: GpuHandle, rt: RenderTargetHandle, color_load: LoadOp, color_store: StoreOp, depth_load: LoadOp, depth_store: StoreOp) → void
end_pass(gpu: GpuHandle) → void
set_uniform_float(gpu: GpuHandle, uniform: UniformHandle, v: float) → void
set_uniform_int(gpu: GpuHandle, uniform: UniformHandle, v: int) → void
set_uniform_vec2(gpu: GpuHandle, uniform: UniformHandle, x,y: float) → void
set_uniform_vec3(gpu: GpuHandle, uniform: UniformHandle, x,y,z: float) → void
set_uniform_vec4(gpu: GpuHandle, uniform: UniformHandle, x,y,z,w: float) → void
set_uniform_mat4(gpu: GpuHandle, uniform: UniformHandle, m: float[16]) → voidColumn-major
set_uniform_texture(gpu: GpuHandle, uniform: UniformHandle, tex: TextureHandle) → void
draw_mesh(gpu: GpuHandle, mesh: MeshHandle, shader: ShaderHandle, prim: Primitive) → void
set_viewport(gpu: GpuHandle, x,y,w,h: int) → void
set_depth_test(gpu: GpuHandle, enabled: bool) → void
set_depth_write(gpu: GpuHandle, enabled: bool) → void
set_blend(gpu: GpuHandle, mode: Blend) → void
set_cull(gpu: GpuHandle, mode: CullMode) → void

Audio ​

The audio platform is a node graph. The signal path is:

[BufferHandle] → [Voice (gain+pan)] → [NodeGraph: user DAG] → [Master (node 0)]

Voices are per-play instances; the node graph is a user-defined DAG of DSP nodes established at init time. Playing a voice routes its output to a designated sink node. Fixed topologies (Buffer→Bus→Master) are special cases of the graph, not the interface.

Opaque handle types (u32, cross-language safe):

  • BufferHandle — decoded audio data; returned by load_audio per sound
  • NodeHandle — a DSP node in the graph (0 = master output, always valid)
  • VoiceHandle — a playing voice instance (0 = invalid). VoiceHandles are monotonically increasing and never reused. Any operation on an invalid or stopped VoiceHandle is a silent no-op.
  • GroupHandle — a voice group for bulk operations

Node kinds: gain | pan | low_pass | high_pass | band_pass | notch | compressor | reverb | delay | mixer

ParamKind — all param values are floats; no wrapper struct needed:

ParamKindUnitNotes
gainlinear (0..∞)amplitude multiplier
pan-1..1-1=L, 0=C, 1=R
cutoffHzfilter cutoff frequency
resonanceQfilter resonance
wet_mix0..1reverb/delay wet blend
decaysecondsreverb tail length
delay_timesecondsecho delay
feedback0..1echo feedback
thresholddBFScompressor threshold (negative)
ratiox:1compressor ratio
attacksecondscompressor/envelope attack
releasesecondscompressor/envelope release
kneedBcompressor knee width

Each node kind accepts only its own params; others throw at runtime.

Setup tier (graph construction, init-time, not perf-critical):

FunctionSignatureNotes
load_audio(name: str, url: str) → BufferHandle (async)Decode one sound; caller loops to load all. Returns the BufferHandle for that sound.
audio_ready() → boolTrue when AudioContext is running and playback is possible; poll before first play call
create_node(kind: NodeKind) → NodeHandleCreate a DSP node
create_voice_group() → GroupHandleCreate a group for bulk voice operations
connect(from: NodeHandle, to: NodeHandle) → voidAdd DAG edge: from.output → to.input
disconnect(from: NodeHandle, to: NodeHandle) → voidRemove edge
set_node_param(node: NodeHandle, kind: ParamKind, value: float, fade_ms: float) → voidSet/animate a node param
get_node_param(node: NodeHandle, kind: ParamKind) → floatRead current param value
destroy_node(node: NodeHandle) → void
destroy_group(group: GroupHandle) → void
destroy_buffer(buffer: BufferHandle) → voidFree decoded audio data

Hot tier (per-frame voice control, all args are primitives — zero object allocation at call site):

FunctionSignatureNotes
play(buffer: BufferHandle, sink: NodeHandle, loop: bool, gain: float, pitch: float, pan: float, offset: float) → VoiceHandlePlay, route through sink
stop(voice: VoiceHandle) → voidStop and discard
stop_all() → voidStop all voices
pause(voice: VoiceHandle) → voidPause, remember position
resume(voice: VoiceHandle) → voidResume from pause position
resume_all() → voidResume all paused voices
is_playing(voice: VoiceHandle) → boolTrue if playing (not paused, not ended)
is_paused(voice: VoiceHandle) → bool
on_voice_end(voice: VoiceHandle, cb: () → void) → voidOne-shot callback fired on natural completion only (buffer exhausted or loop stopped via stop). Not fired by stop(), stop_all(), stop_group(), or stop_node() — callers manage their own cleanup on programmatic stops.
set_voice_gain(voice: VoiceHandle, gain: float, fade_ms: float) → voidPer-voice gain
get_voice_gain(voice: VoiceHandle) → float
set_voice_pitch(voice: VoiceHandle, pitch: float, fade_ms: float) → voidPlayback rate
get_voice_pitch(voice: VoiceHandle) → float
set_voice_pan(voice: VoiceHandle, pan: float) → voidStereo pan
get_voice_pan(voice: VoiceHandle) → float
set_master_gain(gain: float) → voidShorthand for set_node_param(0, gain)
get_position(voice: VoiceHandle) → floatPlayback position in seconds
set_position(voice: VoiceHandle, pos: float) → voidSeek
buffer_duration(buffer: BufferHandle) → floatDuration in seconds
stop_node(node: NodeHandle) → voidStop all voices routed to node
pause_node(node: NodeHandle) → voidPause all voices routed to node
resume_node(node: NodeHandle) → voidResume all paused voices routed to node
add_to_group(group: GroupHandle, voice: VoiceHandle) → voidAdd voice to group
remove_from_group(group: GroupHandle, voice: VoiceHandle) → voidRemove voice from group
stop_group(group: GroupHandle) → voidStop all voices in group
pause_group(group: GroupHandle) → voidPause all voices in group
resume_group(group: GroupHandle) → voidResume all paused voices in group
set_group_gain(group: GroupHandle, gain: float, fade_ms: float) → voidSet gain on all voices in group

Engine-specific audio behaviors (exclusive channels, BGM crossfade, voice stealing by priority, named channels) are shim concerns — implemented by composing the above primitives, not baked into the platform interface.

Non-web backends: the node graph maps directly to Web Audio. For SDL/cpal/Unity targets, implement with miniaudio as the DSP engine and a lightweight graph runner on top. The graph interface is intentionally kept as the canonical surface — expressivity for our own frontends outweighs the porting cost of writing the backend once.

Input ​

Named constant types: DeviceKind: keyboard | mouse | touch | gamepad

Every input source carries a device: int ID. Device 0 is the primary/default for each kind. Multiple devices of the same kind (two keyboards, two mice, split controllers) are distinguished by ID. Use devices(kind) to enumerate on init; on_device_connect/on_device_disconnect handle changes after init.

Gamepad buttons surface through the keyboard callbacks with synthetic codes ("Button0", "Button1", etc.) on their device ID — engines that just want "button pressed" get it for free. Raw analog state uses device_axis.

on_mouse_move delivers absolute canvas coordinates. When pointer-locked, use on_mouse_delta for relative motion — these are distinct concerns and must not be conflated.

Device enumeration:

FunctionSignatureNotes
devices(kind: DeviceKind) → int[]IDs of connected devices of that kind
on_device_connect(cb: (device: int, kind: DeviceKind) → void) → void
on_device_disconnect(cb: (device: int) → void) → void

Keyboard:

FunctionSignatureNotes
on_key_down(cb: (device: int, code: str, key: str) → void) → voidcode = physical key position; key = character produced
on_key_up(cb: (device: int, code: str, key: str) → void) → void
is_key_down(device: int, code: str) → boolPoll current state

Mouse:

FunctionSignatureNotes
on_mouse_down(cb: (device: int, button: int) → void) → void
on_mouse_up(cb: (device: int, button: int) → void) → void
on_mouse_move(cb: (device: int, x, y: float) → void) → voidAbsolute canvas coordinates
on_scroll(cb: (device: int, dx, dy: float) → void) → voidBoth axes
is_mouse_down(device: int, button: int) → bool
mouse_x(device: int) → float
mouse_y(device: int) → float

Pointer lock:

FunctionSignatureNotes
request_pointer_lock() → voidWeb-only; unsupported platforms throw a clear error
release_pointer_lock() → voidWeb-only; unsupported platforms throw a clear error
is_pointer_locked() → boolWeb-only; unsupported platforms throw a clear error
on_mouse_delta(cb: (device: int, dx, dy: float) → void) → voidRelative motion; only meaningful while locked; web-only

Touch:

FunctionSignatureNotes
on_touch_start(cb: (device: int, id: int, x, y: float) → void) → voidid = touch point ID within device
on_touch_move(cb: (device: int, id: int, x, y: float) → void) → void
on_touch_end(cb: (device: int, id: int, x, y: float) → void) → void
touch_count(device: int) → intActive touch points
touch_x(device: int, id: int) → float
touch_y(device: int, id: int) → float

Gamepad (analog):

FunctionSignatureNotes
device_axis(device: int, axis: int) → floatAnalog axis value (-1..1); buttons use keyboard callbacks

Text input:

FunctionSignatureNotes
on_text_input(cb: (device: int, text: str) → void) → voidFired with composed text (post-IME); separate from key events — use for text entry fields. Contract: on_key_down/on_key_up and on_text_input are independent streams. A text field uses on_text_input exclusively; game actions use on_key_down exclusively. Never use both for the same purpose. on_composition_* events provide in-progress IME state for CJK input; on_text_input fires on commit.
on_composition_start(cb: (device: int) → void) → voidIME composition begun; game should display an in-progress composition area
on_composition_update(cb: (device: int, text: str) → void) → voidComposing text changed; text is the current uncommitted composition string
on_composition_end(cb: (device: int, text: str) → void) → voidComposition committed; text is the final string (same text delivered by on_text_input)

Images ​

ImageHandle is an opaque u32. It is defined in a shared types module — not owned by the images concern or the graphics concern. This is what allows graphics_3d to accept ImageHandle in upload_image without importing from the images concern.

Sub-images are views into a parent — no copy. image_width/image_height on a sub-image return the sub-region dimensions. destroy_image on a sub-image releases the view, not the parent. destroy_image on a parent decrements its reference count. The parent is actually deallocated only when the last sub-image is also destroyed (reference counting). GC'd languages do not free native image resources automatically; shims must call destroy_image explicitly (or provide a finalizer/auto-release layer with leak detection).

format in load_image_bytes is a MIME type string ("image/png", "image/webp", etc.), or null to request format sniffing from magic bytes. Use explicit format when known; null as an escape hatch for raw blobs with no format information.

Setup tier (async):

FunctionSignatureNotes
create_image(w, h: int) → ImageHandleCreate a blank (transparent) writable RGBA8 image
load_image_url(url: str) → ImageHandleDecode image from URL
load_image_bytes(data: bytes, format: str | null) → ImageHandleDecode from raw bytes; null format = sniff
create_sub_image(parent: ImageHandle, x, y, w, h: int) → ImageHandleView into parent; no copy

Query (sync):

FunctionSignatureNotes
image_width(handle: ImageHandle) → int
image_height(handle: ImageHandle) → int
read_pixels(handle: ImageHandle, x, y, w, h: int) → bytesRGBA8; throws if unsupported by backend
write_pixels(handle: ImageHandle, x, y, w, h: int, data: bytes) → voidWrite RGBA8 pixels into an image; throws if image is a sub-image view or read-only

Lifecycle:

FunctionSignatureNotes
destroy_image(handle: ImageHandle) → voidReleases view (sub-image) or backing data (root image)

Recommended shim behavior: Track ownership of image handles and release deterministically when game objects are destroyed. If the target language is GC'd, add an auto-release/finalizer layer plus leak logging in debug builds to catch missed destroy_image calls.

Persistence ​

A key-value byte store. "Save" is a shim-level concept; the platform is just storage. Naming reflects that: store/fetch/remove, not save/load.

Contract:

  • init is async — preloads cache, initialises backing store. After init returns, all reads are sync.
  • store and remove are atomic on backends that support it — on failure, the old value remains intact. OPFS and filesystem backends use write-to-temp-then-rename for full crash-consistency. localStorage makes a best-effort but does not guarantee crash-consistency (a browser crash mid-write can corrupt the value); callers should tolerate recovery at startup.
  • store throws on failure — never swallows errors silently. Callers decide how to handle quota exceeded, permission denied, etc.
  • Data is bytes, not strings — strings are just UTF-8 bytes; the more general interface subsumes the string case.
  • Backend composition (OPFS + localStorage tee, fallback, debounce, rolling history) is above the platform layer. A fallback(primary, secondary) utility wrapper handles degradation — the platform interface itself does not decide which backend to use.

Recommended shim behavior: Use a small write-ahead or temp-file strategy on capable platforms and include a recovery step at startup to reconcile partial writes (especially on web storage).

FunctionSignatureNotes
init() → void (async)Preload cache from backing store
store(key: str, data: bytes) → voidAtomic replace; throws on failure
fetch(key: str) → bytes | nullSync read from in-memory cache
remove(key: str) → voidAtomic; throws on failure
list(prefix: str) → str[]Enumerate keys with given prefix

Timing ​

Handles are typed opaque u32s — distinct types prevent mixing delayed/recurring/frame handles. Zero runtime cost (branded types in TS, newtype structs in Rust).

request_frame is the game loop driver — register once, fires every vsync until cancelled. The callback receives a monotonic timestamp so the loop can compute delta time without a separate call.

Handle types: DelayHandle, RecurringHandle, FrameHandle (all u32)

FunctionSignatureNotes
schedule_delayed(cb: () → void, delay_ms: float) → DelayHandleOne-shot; cb fires once after delay
cancel_delayed(handle: DelayHandle) → voidNo-op if already fired or invalid
schedule_recurring(cb: () → void, interval_ms: float) → RecurringHandleRepeating; fires until cancelled
cancel_recurring(handle: RecurringHandle) → void
request_frame(cb: (time_ms: float) → void) → FrameHandleVsync driver; fires every frame until cancelled; time_ms is monotonic
cancel_frame(handle: FrameHandle) → void
current_time_ms() → floatMonotonic clock — for game timing, delta computation
current_wall_time_ms() → floatWall clock — for save file timestamps

Window ​

Window and display management. Fullscreen state is async on browsers (requires user gesture); poll is_fullscreen or use the callback.

Named constant types: CursorShape: default | pointer | text | crosshair | move | resize_ns | resize_ew | none

FunctionSignatureNotes
request_fullscreen() → voidRequest fullscreen; may be deferred until next user gesture
exit_fullscreen() → void
is_fullscreen() → bool
on_fullscreen_change(cb: (fullscreen: bool) → void) → voidFired when fullscreen state changes, including when a request is denied (fires with current state = false)
window_width() → intCurrent window/viewport width in pixels
window_height() → intCurrent window/viewport height in pixels
on_resize(cb: (w, h: int) → void) → voidFired when the window/viewport size changes
device_pixel_ratio() → floatPhysical pixels per CSS pixel; use to size canvas backing buffers for hi-DPI displays
on_focus_change(cb: (focused: bool) → void) → voidFired when the game window gains or loses focus
on_visibility_change(cb: (visible: bool) → void) → voidFired when the page/tab becomes hidden or visible; use to pause the game loop
set_cursor(canvas: CanvasHandle, shape: CursorShape) → voidSet the OS cursor appearance scoped to a canvas; none hides the cursor

Clipboard ​

Text-only clipboard access. read_clipboard and write_clipboard are async and may require user permission on browsers; unsupported platforms throw a clear error.

FunctionSignatureNotes
read_clipboard() → str | null (async)Read current clipboard text; null if clipboard is empty or contains non-text; throws if permission denied
write_clipboard(text: str) → void (async)Write text to clipboard; throws if permission denied or unavailable
on_paste(cb: (text: str) → void) → voidFired when the user pastes (e.g. Ctrl+V); delivers text without requiring a permission prompt

Network ​

Minimal HTTP client. Sufficient for leaderboards, analytics, and asset streaming. More complex patterns (WebSockets, SSE) are composed above this layer.

FunctionSignatureNotes
fetch_url(url: str, method: str, headers: {str: str}, body: bytes | null) → {status: int, body: bytes} (async)HTTP request; throws only on network failure (no response received). Non-2xx status codes are returned as data — caller decides what constitutes an error.

Rust trait examples omitted — see canonical function tables above. Language-specific signatures derive from those tables.

TypeScript: module re-exports ​

typescript
// platform/index.ts — resolved at build time
export { setTransform, fillRect, drawImage, drawText, ... } from "./browser";

The API shim imports platform functions directly:

typescript
// flash/display.ts
import { setTransform, fillRect, drawImage } from "../platform";

export class DisplayObject {
    render(): void {
        setTransform(this.transform.matrix);
        // ... draw children
    }
}

The bundler resolves platform/index.ts to the chosen implementation at build time. Tree-shaking eliminates the unused one. The result is direct function calls with zero indirection — equivalent to inlining the browser API calls, but with a clean abstraction boundary.

To swap implementations, change the re-export source or use bundler path aliasing (Vite, webpack, esbuild all support this). No runtime cost.

Relationship to current system traits ​

The system/ traits in reincarnate-core (Renderer, Audio, Input, SaveLoad, Timing, Ui) were designed as the platform interface but at the wrong abstraction level. Renderer::draw_sprite() assumes a sprite-based engine, but Flash uses a display list. Ui::show_message() is an engine-level concept, not a platform capability.

The platform interface should be redesigned as:

  • Graphics replaces Renderer — 2D drawing primitives, not sprite operations. The display list is the API shim's concern.
  • Audio stays as-is — sound buffer management is platform-level.
  • Input stays as-is — key/mouse state is platform-level.
  • Persistence replaces SaveLoad — simpler key-value API without serde dependency (serialization is the API shim's concern).
  • Timing stays as-is — frame pacing is platform-level.
  • Ui is removed — dialogue boxes and menus are engine-level, not platform-level. Each engine's API shim implements its own UI system using the graphics and input capabilities.

IR Design ​

Entity-Based Arenas ​

All IR nodes live in typed arenas (PrimaryMap<K, V>) and are referenced by u32 indices:

FuncId(0) → Function { name: "main", blocks: [BlockId(0), BlockId(1)] }
BlockId(0) → Block { params: [ValueId(0)], insts: [InstId(0), InstId(1)] }
InstId(0) → Inst { op: Op::Const(42), result: ValueId(1) }

Cache-friendly, trivially serializable, no lifetimes or Rc/Arc.

Block Arguments (not Phi Nodes) ​

Following Cranelift and MLIR, the IR uses block arguments instead of phi nodes. Simpler to construct from frontends, easier to reason about.

block0(v0: i32):
    v1 = add v0, 1
    br block1(v1)

block1(v2: i32):
    return v2

Operations ​

The Op enum covers:

  • Constants: integers, floats, strings, booleans, null
  • Arithmetic/logic: standard ALU operations
  • Control flow: branch, conditional branch, switch, return
  • Memory: load, store, alloc, field access
  • Calls: direct call, indirect call, system call
  • Type operations: cast, type check, dynamic dispatch
  • Coroutines: yield, create, resume
  • Aggregates: struct/array/tuple construction and access

SystemCall ​

SystemCall { system: "Graphics", method: "draw_image", args: [v0, v1, v2] }

String-based at IR level, resolved to concrete trait method calls at codegen. Keeps the Op enum from exploding with per-engine operations.

Coroutines as First-Class ​

Yield, CoroutineCreate, CoroutineResume are IR operations. A transform pass lowers them to state machines before codegen. Different backends may lower differently:

  • Rust: state machine enum or async/await
  • TypeScript: generator functions

Module Scope ​

Entity IDs are module-scoped: FuncId(0) in Module A ≠ FuncId(0) in Module B. Cross-module references use string-based imports. A linking pass builds the global symbol table.

Backend AST Design ​

Each backend defines its own AST types (e.g. JsStmt/JsExpr for TypeScript) separate from the core Stmt/Expr. This two-type design is intentional:

Why not a single unified AST? Backends need language-specific constructs (new, typeof, in, delete, throw, super.* for JS) that don't belong in core. Polluting core with JS-isms would violate engine neutrality.

Why not a generic/parameterized AST? Expr<Ext> parameterized over an extension type still requires a deep copy when converting between Expr<CoreExt> and Expr<JsExt>, since every Box child changes type. No zero-copy win.

Why not a builder trait? If the linearizer constructed through trait AstSink, it could produce backend AST directly — no intermediate core AST. But the AST normalization passes (forward substitution, ternary rewrite, constant folding, etc.) need to pattern-match and transform a concrete tree. The builder is write-only. Since the core AST is needed for the passes regardless, the builder can't eliminate it.

Current design: IR → core AST → normalization passes → mechanical lowering → backend AST → engine-specific rewrites → printer. The mechanical lowering (lower.rs) is a boring O(n) tree copy — the cost of having two type hierarchies. Engine-specific rewrites (e.g. Flash) operate on the backend AST (JsExpr → JsExpr), keeping them decoupled from both the core types and the printer.

AST Normalization Passes ​

After the backend lowers IR to an AST (Vec<Stmt>), a pipeline of rewrite passes normalizes the output for readability. Passes run in a fixed order; some are in a fixpoint loop that iterates until no further changes occur.

Cleanup Phase (one-shot, before fixpoint) ​

PassEffect
eliminate_self_assignsRemove x = x
eliminate_duplicate_assignsCollapse consecutive identical assignments
eliminate_forwarding_stubsRemove uninit phi + immediate read into another phi
invert_empty_thenif (x) {} else { B } → if (!x) { B }
eliminate_unreachable_after_exitTruncate dead code after return/break/continue or if-else where both branches exit
rewrite_ternaryif (c) { x = a } else { x = b } → x = c ? a : b
rewrite_minmax(a >= b) ? a : b → Math.max(a, b)

Fixpoint Phase ​

Runs in a loop until statement count stabilizes:

PassEffect
forward_substituteInline single-use adjacent assignments
rewrite_ternary(re-run to catch newly exposed patterns)
simplify_ternary_to_logicalc ? x : c → c && x, c ? c : x → c || x
absorb_phi_conditionMerge split-path phi booleans into their assigning branch
narrow_var_scopePush uninit let into the single child scope where all refs live
merge_decl_initlet x; ... x = v → let x = v
fold_single_use_constsInline single-use const/let declarations

Final Phase (one-shot, after fixpoint) ​

PassEffect
rewrite_compound_assignx = x + 1 → x += 1
rewrite_post_incrementRead-modify-write → x++

Multi-Platform Strategy ​

PlatformGraphicsAudioInputPersistence
Desktop (Linux/macOS/Windows)wgpucpalwinitfilesystem
Mobile (iOS/Android)wgpucpalwinitfilesystem
WASM (Rust backend)wgpu (WebGPU)Web Audiowinit (canvas)IndexedDB
Web (TypeScript backend)Canvas 2D / WebGLWeb AudioDOM eventslocalStorage

Platform differences are abstracted at the platform interface level. The same API shim (engine runtime) runs on any target by swapping the platform implementation. For Rust targets, the platform traits monomorphize. For TypeScript, the bundler resolves the platform module at build time.

Persistence & Save System ​

See Persistence & Saving for the full design. Key points:

  • Save state (current moment) and history (undo stack) are separate concerns with independent persistence and retention policies.
  • The engine calls commit(state) after each transition; the platform handles persistence strategy (when, where, how many copies).
  • Three composable axes: state transforms (what to save), timing (when to save), backends (where to save). Deployers compose these at init.
  • History supports diff-based (store only changed variables per transition) or snapshot-based (full clone) strategies, chosen per game's state size.
  • Default: continuous autosave with debouncing, upgrading every engine's original save system.

Architecture Decision Records ​

See adr/ for design decisions:

Dependencies ​

  • swf crate (from Ruffle, MIT/Apache-2.0): SWF file parsing for the Flash frontend
  • thiserror: Structured error types in core
  • serde / serde_json: Project manifest and asset catalog serialization