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
| Layer | Ships in game? | What lives here | May import |
|---|---|---|---|
| core | ✅ | Zero-dependency primitives: math/, signals.js, constants.js, dom.js, diagnostics.js, hosting.js, field-registry.js | nothing internal |
| runtime | ✅ | The 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.js | core |
| behavior | ✅ | Runtime-side visual scripting: rules/ (behavior model + runner) and flow/ (node-graph engine, compiler, store). Rules compile into Flow graphs. | core, runtime, project* |
| project | ✅ | Serialization & 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.js | core, runtime, behavior* |
| editor | ❌ | Author-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 |
| app | ❌ | Composition 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.js | anything |
* 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) | Opal | Layer |
|---|---|---|
| GameObject / Node | Thing (thing.js) | runtime |
| MonoBehaviour / Node script | defineComponent + ComponentHost (components/) | runtime |
| SceneTree | customObjects + scene-graph.js | runtime |
| Animation / Rigging | skeleton/ + motion/ | runtime |
| uGUI / Control | ui-runtime/ | runtime |
| PhysicsServer | physics/ (Rapier) | runtime |
| Bolt / VisualScript | rules/ + flow/ | behavior |
| .scene / .tscn + AssetDatabase | scene-serialization.js, scene-store.js, assets/ | project |
| Unity Editor | scene-editor/, inspector/, interaction/, studios | editor |
| Engine bootstrap / main loop | src/app/create-editor-app.js + bootstrap-runtime.js (main.js is the browser entry) | app |
| Standalone player | src/player.js | app |
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
ComponentHostreceives 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
scalevalue. Non-uniform scale, nested axis flips, or matrix-preserving reparenting require a future matrix-basedTransform2Dmigration; they are not implicit promises of the current transform contract. - Editable node workspaces build on the shared
GraphSurfaceinscene-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 arebehavior;interaction/iseditor. A future rename tobehavior/model,behavior/graph, andeditor/graph-editorwould 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):
| Was | Rule | How it was fixed |
|---|---|---|
renderer.js / scene-camera-resolver.js → gizmo modules | runtime → editor | Step 1: inject gizmo painters + camera thumbnail |
thing-fields.js → inspector/field-registry.js | runtime → editor | Step 2: relocate the DOM-free registry to game root (core) |
flow/catalog.js → inspector/core.js | behavior → editor | Step 3: move flowAction to flow/action-defaults.js |
scene-serialization.js → scripts/index.js | project → editor | Step 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.js → scene-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.jsmixes 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. (ThecollectCount/collectTotalStars-HUD counter was removed with the forced Stars HUD — match-puzzle retirement tier A.) Namespacing the survivors intoengine/editoris deliberately not done: it's hundreds of unguardedstate.xrewrites 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 oldmain.js).main.jsis now a 6-line browser entry: it callscreateEditorApp()(src/app/create-editor-app.js), which runs the static phases (src/app/phases/dom|core|services.js), the world context, and thensrc/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.jspluseditor-frame.js(the RAF loop) and the earlierproject-health|asset-browser|viewporttrio; older extractions moved the non-orchestration concerns intoeditor-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.jsboots the realcreateEditorApp()headless (fake globals + fake-indexeddb + a recording canvas, viasrc/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 innpm testinstead 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 Studiomotion/editor/— Motion Studioskeleton/skeleton-studio/— Skeleton Studio + toolsassets/editor/— asset browser, layer context menuscripts/editor/— Script Studio, language service, Monaco, worker (the modelproject.jsand compilercompiler.js/expression.jsstay atscripts/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.
- Land the map + guard ✅ (done). The rule becomes executable, the debt is pinned, and regressions are blocked. No engine code moves.
- 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_VIOLATIONSentries. Groundwork foronEditorDraw. - Relocate
field-registry.js✅ (done). Moved frominspector/to game root and classifiedcore(it's zero-dependency, serving both the runtime field model and the editor inspector).thing-fields.js→field-registry.jsis now runtime → core. Removed itsKNOWN_VIOLATIONSentry. - Decouple serialization & behavior from the editor ✅ (done).
flowActionmoved toflow/action-defaults.js(behavior);inspector/core.jsre-exports it so editor consumers are untouched. Serialization now imports the project-script model/serializer fromscripts/project.js(project) and the compiler fromscripts/compiler.js(behavior), bypassing the editor barrel.KNOWN_VIOLATIONSis now empty. - 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: fullengine/editornamespacing was assessed as high-churn / unguarded / low-payoff and deliberately skipped. - 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 intoskeleton/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. - Decompose
main.js✅ (done — scoped). Extracted the cohesive non-orchestration concerns intoeditor-dom.js,editor-theme.js, andeditor-artboard.js(2156 → ~1897 lines), each verified bynode --check+ guard + full suite + a manual boot. The forward-declared-handle → registry conversion was deliberately skipped (see "main.jsis 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:
- Statically scans every non-test
.jsundersrc/forimport … from "…",export … from "…", dynamicimport("…"), and side-effectimport "…"specifiers. - Classifies both endpoints into layers (subfolder prefix rules + a few explicit overrides for loose game-root files).
- Flags every edge that points upward (target layer above importer layer).
- Subtracts
KNOWN_VIOLATIONSand asserts the remainder is empty — so new upward edges fail the build. - Asserts no
KNOWN_VIOLATIONSentry is stale (every listed edge must still exist) — so fixing a violation forces removing its entry, and the list can't rot. - 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.