Skip to content

Opal Architecture

Opal is a vanilla-JS 2D game engine with an integrated editor, loaded as ES modules directly by the browser (no bundler for the editor; the optional npm run build:player path emits a static player). This document maps the engine into layers, states the one dependency rule that keeps those layers from fusing, and records the current debt against that rule.

Folder layout is feature-organized and largely aligned with layers (editor code lives in each feature's editor/ / studio subfolder — see Folder ↔ layer alignment). A few loose editor files still sit at src/game/ root. The machine-readable, always-current source of truth for "which file is in which layer" is the guard test: src/game/architecture-boundaries.test.js. When the prose here and the test disagree, the test wins — update this doc.


The one rule

Dependencies point downward. A layer may import from its own layer and any layer below it, never above.

        app          src/app/ (createEditorApp, bootstrap-runtime, phases),
         │            src/main.js (6-line browser entry), cutout-tool,
         │            game/index.js barrel, app-state — may wire anything

       editor         scene-editor/, inspector/, interaction/, the studios,
         │            gizmos, asset browser UI, hierarchy, undo/redo commands,
         │            graph-explorer/

   behavior   project rules/ + flow/   ·   serialization, scene-store, prefab,
         │      │      (visual scripting)   assets/, db, project-package
         ▼      ▼      (siblings — cross-imports allowed for now, but noted)
       runtime        thing, scene-graph, renderer, components/, physics/,
         │            skeleton/, motion/, input/, ui-runtime/, audio, vfx,
         ▼            play-runtime, camera, network — *ships inside the game*
        core          math, signals, constants, dom, diagnostics, hosting,
                      field-registry (imports nothing internal)

The cardinal case of this rule — the one that causes the most pain when broken — is runtime must never import editor. This is the exact line Unity draws with its Editor/ assemblies (stripped from player builds) and Godot draws with @tool (the editor is built on the runtime, never the reverse). It is also the foundation for editor-only tooling (waypoint editors, nav widgets, the scene camera gizmo): "editor-only" is only a clean, enforceable concept once "editor" is a layer rather than a scattered if (isSceneCameraThing(...)) check.

Why this rule and not a looser one: when runtime can't reach editor code, the compiler/test — not developer discipline — forces editor-only concerns out of the render/simulate path. The boundary does the cleanup for you.


Layers

LayerShips in game?What lives hereMay import
coreZero-dependency primitives: math/, signals.js, constants.js, dom.js, diagnostics.js, hosting.js, field-registry.jsnothing internal
runtimeThe simulate/render/play engine: thing.js, scene-graph.js, renderer.js, components/, physics/, skeleton/ (model/player/renderer), motion/ (clips/player/state-machine), input/, ui-runtime/ (model/layout/render), audio.js, vfx.js, play-runtime.js, camera-runtime.js, network-session.js, event-registry.jscore
behaviorRuntime-side visual scripting: rules/ (behavior model + runner) and flow/ (node-graph engine, compiler, store). Rules compile into Flow graphs.core, runtime, project*
projectSerialization & assets: scene-serialization.js, scene-store.js, prefab-instantiate.js, project-package.js, db.js, assets/ (library/scene-assets/migration), scene-session.js, bundled-content.jscore, runtime, behavior*
editorAuthor-time UI & tooling: scene-editor/, inspector/, interaction/ (the graph editor UI), the studios (Component/Script/Motion/Skeleton/UI-design), gizmos, assets/browser-ui.js, graph-explorer/, undo/redo commands/anything below
appComposition root: src/app/ (createEditorApp, bootstrap-runtime, phases), src/main.js (browser entry), src/player.js (standalone player entry), src/cutout-tool.js, src/game/index.js barrel, app-state.jsanything

* behavior and project are siblings at the same rank; today they cross-import in both directions (flow↔rules internally; assets/serialization reach into behavior). The guard permits sibling imports but the count is tracked so the coupling stays visible.

How layers map to engine concepts

Concept (Unity / Godot)OpalLayer
GameObject / NodeThing (thing.js)runtime
MonoBehaviour / Node scriptdefineComponent + ComponentHost (components/)runtime
SceneTreecustomObjects + scene-graph.jsruntime
Animation / Riggingskeleton/ + motion/runtime
uGUI / Controlui-runtime/runtime
PhysicsServerphysics/ (Rapier)runtime
Bolt / VisualScriptrules/ + flow/behavior
.scene / .tscn + AssetDatabasescene-serialization.js, scene-store.js, assets/project
Unity Editorscene-editor/, inspector/, interaction/, studioseditor
Engine bootstrap / main loopsrc/app/create-editor-app.js + bootstrap-runtime.js (main.js is the browser entry)app
Standalone playersrc/player.jsapp

Runtime ownership invariants

  • A Thing may assemble parent links before entering a world. Once claimed by a SceneGraph, public hierarchy changes must go through that graph so ordering, cache invalidation, and the monotonic hierarchy version stay coherent.
  • A ComponentHost receives runtime services from the rule system for its own world. There is no module-level runtime provider; isolated hosts and tests must bind their provider explicitly.
  • Scene hierarchy transforms intentionally support one uniform scale value. Non-uniform scale, nested axis flips, or matrix-preserving reparenting require a future matrix-based Transform2D migration; they are not implicit promises of the current transform contract.
  • Editable node workspaces build on the shared GraphSurface in scene-editor/graph-surface.js. Domain canvases own their node definitions and card content, not duplicate selection, pan/zoom, wiring, or undo gestures.

Naming note: the visual-scripting stack reads as three unrelated folders but is actually a 3-layer pipeline — rules/ (behavior model + runtime runner), flow/ (the node-graph engine: compile/store/eval), interaction/ (the graph editor UI). Only the first two are behavior; interaction/ is editor. A future rename to behavior/model, behavior/graph, and editor/graph-editor would make the pipeline legible.


Current debt against the rule

None. Every one of the ~285 cross-layer import edges now points downward — the KNOWN_VIOLATIONS allowlist in architecture-boundaries.test.js is empty, and the guard fails the build the instant a new upward edge is introduced.

The three original violations were cleared in migration steps 1–3 (see Migration path):

WasRuleHow it was fixed
renderer.js / scene-camera-resolver.js → gizmo modulesruntime → editorStep 1: inject gizmo painters + camera thumbnail
thing-fields.jsinspector/field-registry.jsruntime → editorStep 2: relocate the DOM-free registry to game root (core)
flow/catalog.jsinspector/core.jsbehavior → editorStep 3: move flowAction to flow/action-defaults.js
scene-serialization.jsscripts/index.jsproject → editorStep 3: import from scripts/project.js + compiler.js directly

This is an invariant, not a victory lap: the value is the guard, which keeps the count at zero. The remaining work below is structural hygiene (folder layout, the state bag, the god object) — none of it is an import-rule violation.

Resolved: the runtime↔editor camera cycle

Originally a true cycle dragged editor drawing into the core render path: renderer.js and scene-camera-resolver.js (both runtime) imported the editor gizmo modules, while scene-camera-gizmo.js imported field accessors back from the resolver. Migration step 1 broke it: the renderer now receives its gizmo painters as an injected gizmos object (wired in src/app/phases/services.js, the app layer), and the resolver takes the camera's thumbnail as an injected thumbSrc param instead of importing the gizmo. The one remaining edge — scene-camera-gizmo.jsscene-camera-resolver.js — is editor → runtime, which is downward and legal. This also lays the groundwork for moving the camera gizmo onto a generic component onEditorDraw hook.


Other structural debt (not import violations, but the reason for the rule)

  • app-state.js mixes engine + editor state in one flat object (app-state.js): engine/viewport (viewport, camera, sceneCamera) and editor (editorOpen, selected, uiDesignMode, onionSkinning) live side by side. The actual game-specific leakage — the pet-care/coloring leftovers (fed, clean, coloring, activeCrayon, mudSpots, puzzleDone, showDefaultDino) — was dead code and has been deleted (migration step 4). What looked game-specific but is actually live is generic engine/runtime feedback (celebration, completedGroups) tied to built-in tap-actions, not any one game. (The collectCount/collectTotal Stars-HUD counter was removed with the forced Stars HUD — match-puzzle retirement tier A.) Namespacing the survivors into engine/editor is deliberately not done: it's hundreds of unguarded state.x rewrites for cosmetic gain. If real per-project persistent state ever appears, its home is component fields + project scripts, not a revived bag.

  • The composition root is src/app/ (step 6 — extracted from the old main.js).main.js is now a 6-line browser entry: it calls createEditorApp() (src/app/create-editor-app.js), which runs the static phases (src/app/phases/dom|core|services.js), the world context, and then src/app/bootstrap-runtime.js — the ~590-line live root that constructs and cross-wires the subsystems. The cohesive chunks live as real phases it calls: phases/topbar|dock|graph-explorer|boot.js plus editor-frame.js (the RAF loop) and the earlier project-health|asset-browser|viewport trio; older extractions moved the non-orchestration concerns into editor-dom.js, editor-theme.js, editor-artboard.js. The "system registry" conversion was deliberately not done, on evidence: the forward-declared system handles are referenced ~190 times, so converting them to a registry is a wide, error-prone rewrite of the boot path. The root is no longer browser-boot-only, though: src/app/editor-boot.test.js boots the real createEditorApp() headless (fake globals + fake-indexeddb + a recording canvas, via src/app/test-support/editor-boot-harness.js), opens a seeded project, and pumps the live frame loop — construction-order or boot-chain regressions now fail in npm test instead of shipping silently.

  • Folder ↔ layer alignment (resolved in step 5). Opal is feature-organized: each feature (components/, motion/, skeleton/, ui-runtime/, flow/, …) bundles its runtime model with its own editor tooling — the same colocation Godot uses for its modules. The convention that makes the layer visible at a glance: editor-only code inside a feature folder lives in that feature's editor subfolder.

    • components/editor/ — inspector card, Component Studio
    • motion/editor/ — Motion Studio
    • skeleton/skeleton-studio/ — Skeleton Studio + tools
    • assets/editor/ — asset browser, layer context menu
    • scripts/editor/ — Script Studio, language service, Monaco, worker (the model project.js and compiler compiler.js/expression.js stay at scripts/ root)
    • ui-runtime/edit/ — the UI design tool

    The guard classifies by these subfolder prefixes (no per-file overrides), so a stray editor import from a feature's runtime code is caught. The only remaining per-file editor overrides are loose top-level files at src/game/ root (e.g. transform-gizmo.js, editor-layout.js) — relocating those into an editor home is optional future polish.


Migration path

Incremental and guarded. A 2140-line composition root and interleaved layers make a big-bang rewrite all-risk; instead, ratchet down one slice per PR, each green under the guard.

  1. Land the map + guard(done). The rule becomes executable, the debt is pinned, and regressions are blocked. No engine code moves.
  2. Break the camera cycle(done). Gizmo painters injected into the renderer from the app layer; camera data (resolver) separated from camera visuals (gizmo). Removed three KNOWN_VIOLATIONS entries. Groundwork for onEditorDraw.
  3. Relocate field-registry.js(done). Moved from inspector/ to game root and classified core (it's zero-dependency, serving both the runtime field model and the editor inspector). thing-fields.jsfield-registry.js is now runtime → core. Removed its KNOWN_VIOLATIONS entry.
  4. Decouple serialization & behavior from the editor(done). flowAction moved to flow/action-defaults.js (behavior); inspector/core.js re-exports it so editor consumers are untouched. Serialization now imports the project-script model/serializer from scripts/project.js (project) and the compiler from scripts/compiler.js (behavior), bypassing the editor barrel. KNOWN_VIOLATIONS is now empty.
  5. Clean up app-state.js(done — scoped). Deleted the 7 dead pet-care/coloring fields (the real domain leakage). Left engine + editor flat-but-clean: full engine/editor namespacing was assessed as high-churn / unguarded / low-payoff and deliberately skipped.
  6. Align folders with layers(done). Adopted the per-feature editor/ subfolder convention and moved the editor code into it — motion/editor/, components/editor/, assets/editor/, scripts/editor/, and the skeleton studio files into skeleton/skeleton-studio/. ~11 files moved, all import paths rewritten, verified by the guard's import-resolution test + the full suite. The guard now classifies by subfolder, not per-file overrides.
  7. Decompose main.js(done — scoped). Extracted the cohesive non-orchestration concerns into editor-dom.js, editor-theme.js, and editor-artboard.js (2156 → ~1897 lines), each verified by node --check + guard + full suite + a manual boot. The forward-declared-handle → registry conversion was deliberately skipped (see "main.js is the composition root" above): ~190 references, object-shorthand breakage, and zero automated coverage make it an unverifiable boot-path rewrite for modest gain.

After each step, delete the corresponding allowlist entries; the guard's stale-entry check forces you to (a fixed violation left in the list fails the test).


How the guard works

architecture-boundaries.test.js runs under npm test (it matches the src/game/*.test.js glob). It:

  1. Statically scans every non-test .js under src/ for import … from "…", export … from "…", dynamic import("…"), and side-effect import "…" specifiers.
  2. Classifies both endpoints into layers (subfolder prefix rules + a few explicit overrides for loose game-root files).
  3. Flags every edge that points upward (target layer above importer layer).
  4. Subtracts KNOWN_VIOLATIONS and asserts the remainder is empty — so new upward edges fail the build.
  5. Asserts no KNOWN_VIOLATIONS entry is stale (every listed edge must still exist) — so fixing a violation forces removing its entry, and the list can't rot.
  6. Asserts every relative import resolves to a real file on disk — there's no bundler, so a typo or a bad move is otherwise a silent 404 in the browser, invisible to node tests that never import the broken module. (This caught a side-effect import during the step-5 moves.)

To fix a violation: remove the upward import, then delete its KNOWN_VIOLATIONS entry. To (knowingly) add one: add the edge to KNOWN_VIOLATIONS with a comment — the friction is the point. To move a file between layers: update the overrides in the guard and this doc's tables.

Opal Engine — MIT licensed