Files
Bevy_Forge/docs/architecture.md

20 KiB

Bevy Forge architecture

This page describes how the Bevy Forge workspace is put together: the crates, the scene model, the editor's panel/action model, persistence, code generation and the testing strategy. It documents the architecture the code is being built against; individual modules may still be in progress in a given checkout.

Design constraints

  1. No Bevy dependency in the editor. bevy_forge_core and bevy_forge_editor depend only on serde/RON/IndexMap/thiserror/toml in core and eframe/egui/image in the editor. The only crate that links Bevy is the excluded compat/bsn-smoke verification crate. This keeps editor builds fast and independent of Bevy's release cadence.
  2. Export is source code. A .forge.ron scene stores editor data only. BSN or imperative Rust is generated on demand and never stored in the scene file. Code generation is deterministic for a given document and options.
  3. One scene format. .forge.ron (RON via serde) is the only scene extension. It is Forge's own format, not Bevy's .scn.ron dynamic-scene format.
  4. Snapshot undo. The document is small enough that full snapshots are simpler and safer than inverse operations. Every undoable mutation goes through one session API.
  5. Immediate-mode UI with deferred actions. Panels render and return Action values. Only the app shell performs file I/O, spawning, saving and exporting.

Workspace at a glance

                  +--------------------------------+
                  |        bevy_forge_core         |
                  |  model - project - validation  |
                  |  history - assets - codegen    |
                  +---------------+----------------+
                                  ^
                    depends on    |    depends on
             +--------------------+--------------------+
             |                                         |
+------------+-------------+              +------------+-------------+
|   bevy_forge_editor      |              |     bevy_forge_cli       |
|  eframe/egui application |              |  clap binary `forge`     |
|  binary: `bevy-forge`    |              |                          |
+--------------------------+              +--------------------------+

  compat/bsn-smoke (excluded from the workspace, depends on Bevy 0.20.0)
  compiles representative generated BSN to prove the export is valid Rust.
Crate / directory Role Binary / artifact
crates/bevy_forge_core Document model, structural ops, project and scene persistence, undo, asset index, validation, code generation library
crates/bevy_forge_editor Native editor (eframe/egui): panels, viewports, dialogs, palette, toasts bevy-forge
crates/bevy_forge_cli forge new, validate, export, info, scenes forge
examples/demo-project Real Bevy 0.20 project used as the first-run demo and screenshot subject not built by the workspace
compat/bsn-smoke Compiles generated BSN against real Bevy 0.20.0; excluded from the workspace standalone crate
scripts/ End-to-end generated-code check shell script

The root Cargo.toml declares the three workspace members and excludes compat/bsn-smoke so Bevy is never built as part of the normal workspace.

Core model

All types below are public and frozen for this release.

Type Module Meaning
EntityId (u64) scene Stable entity identity inside one scene file
SceneDoc scene format_version, name, ordered entities, ordered roots, next_id
EntityNode scene id, name, parent, ordered children, mandatory transform, components, editor-only locked
Transform math translation: Vec3, rotation: Vec3 (XYZ Euler degrees), scale: Vec3
ComponentData components Tagged enum of built-in components plus Custom(CustomComponent)
Session history Document + undo/redo history + dirty flag
History / Snapshot history Bounded stack of full-document snapshots (default limit 100)
Project project Root, package name, Bevy version, assets dir; scene load/save/list
AssetIndex / AssetEntry / AssetKind assets Recursive index of the project's asset files
ValidationReport / Issue / Severity validation Deterministic validation output
ExportFormat / CodegenOptions codegen Export dispatch and options (function name, header)
EditorConfig config Recent projects and editor preferences

Scene identity and ordering

  • IDs are u64, start at 1 and are never reused within a scene; next_id is monotonic.
  • Each entity has exactly one parent (None for roots). Cycles are rejected by reparent.
  • roots and every children vector define display order and are preserved by save/load.
  • SceneDoc::entities is an IndexMap; insertion order is stable for serialization.
  • EntityNode.id must equal its map key; SceneDoc::check_integrity() reports mismatches, dangling parents/children, duplicate references and wrong root/child links.
  • Every structural edit must leave check_integrity() == [].
  • locked is editor-only metadata: it blocks viewport transforms, not deletion or hierarchy edits.

Transform semantics

Transform.rotation stores XYZ Euler angles in degrees because that is what the inspector edits. Hierarchy composition converts degrees to a quaternion (quat_from_euler_deg), composes parent and child, and converts back to Euler degrees. Export converts the other way with Quat::from_euler(EulerRot::XYZ, x.to_radians(), y.to_radians(), z.to_radians()). There is no ambiguity between "editor degrees" and "Bevy radians" because conversion only happens at the composition and code-generation boundaries.

Name and Transform are node fields in .forge.ron, not entries in components; the BSN and Rust generators always emit them for every entity.

Components

ComponentData is a serde-tagged enum:

  • Sprite(SpriteData), Text2d(Text2dData), Camera2d, Camera3d, PointLight(PointLightData), DirectionalLight(DirectionalLightData), Mesh3d(Mesh3dData), Visibility(VisibilityMode), Custom(CustomComponent).
  • ComponentData::key() is the uniqueness key: "sprite", "text2d", "camera2d", "camera3d", "point_light", "directional_light", "mesh3d", "visibility", and "custom:<type_path>". add_component refuses duplicate keys; custom components are unique per type path, so two different custom types can coexist.
  • Mesh3dData holds MeshSource (Primitive(PrimitiveShape) or Asset(String)) and MaterialData. Primitives are cuboid, sphere, plane, cylinder and capsule.
  • CustomComponent stores a Rust type path plus a list of (name, expr) fields. Expressions are raw Rust/BSN text, validated only for identifier shape and non-emptiness.

Undo/redo

Session wraps the document and owns the history:

// One undo entry, recorded only if the document actually changed.
session.edit("Rename entity", |doc| {
    doc.rename(id, "Player");
});

// Continuous interactions (gizmo drags): snapshot before, mutate every frame,
// commit once on release.
let before = session.doc().clone();
// ... update transforms during the drag ...
session.commit(before, "Move entities");
  • edit clones the document before running the closure and records a snapshot only when the result differs.
  • commit accepts a caller-supplied pre-edit snapshot and records it once at the end of a drag.
  • doc_mut is the escape hatch for read-modify-write UI code: it marks the session dirty but adds no undo point, so it must not be used for user-visible edits.
  • undo/redo swap snapshots and return the action label; the app puts that label in the status bar.
  • mark_saved clears the dirty flag, replace swaps in a new document and clears history (used when opening or creating a scene), and is_dirty drives the title dot, autosave and quit guard.
  • A new edit clears the redo stack. The default history limit is 100 snapshots.

Persistence

Opening a project

Project::open(root) requires an existing directory with a Cargo.toml containing a bevy dependency (normal, workspace or dev dependencies; a string or a { version = ... } table). It canonicalizes the root, reads the package name, and records the Bevy version string. The assets directory is <root>/assets when it exists, otherwise <root> (so projects without a dedicated assets folder still work).

Scenes live in <root>/scenes/*.forge.ron. Project::load_scene parses RON and normalizes a legacy format_version: 0 to the current version in memory. Project::save_scene accepts an optional path (defaulting to a slugified scene name), refuses paths outside the project root, creates parent directories, serializes with the pretty RON printer and writes atomically: a temporary file .<name>.tmp-<pid> in the target directory, then rename over the destination.

Autosave and configuration

When autosave is enabled (autosave_secs > 0, default 60), the app writes a dirty document to the project's .forge/autosave.forge.ron no more often than that interval. Autosave never marks the session saved and never replaces an explicit save; .forge/ is ignored by version control.

Editor preferences (recent projects, last project, grid/snap/show-assets flags, autosave interval) are stored as RON in the platform config directory, honouring XDG_CONFIG_HOME on Unix: <config>/bevy-forge/config.ron.

Assets and validation

AssetIndex::scan(assets_dir) recursively indexes regular files under the assets directory, skipping hidden entries, symlinks, and target / build / node_modules directories, with a depth cap. Paths are /-separated and relative to the assets directory and are sorted for determinism. Files are classified by extension into Image, Mesh, Audio, Font, Scene, Shader, Material and Other. A missing or unreadable assets directory yields an empty index with an error string; the editor shows that state instead of failing.

validate_scene(doc, assets: Option<&AssetIndex>) -> ValidationReport runs structural checks, range checks and (only when an index is supplied) asset existence/kind checks. Severities order as Info < Warning < Error; the report offers is_ok, errors, warnings, count, by_entity and sorted. Validation must never panic on malformed forests and is deterministic (severity descending, then entity, then message). See the format reference for the rule list.

Code generation

pub enum ExportFormat { Bsn, Rust }

pub struct CodegenOptions {
    pub function_name: String, // a Rust identifier that is not a keyword
    pub include_header: bool,
}

The dispatch method is ExportFormat::generate(self, doc: &SceneDoc, options: &CodegenOptions) -> Result<String, CodegenError>, implemented in codegen.rs and forwarded to codegen::bsn or codegen::rust.

  • codegen.rs owns the shared options and dispatch; codegen/bsn.rs emits Bevy 0.20 BSN and codegen/rust.rs emits imperative Commands::spawn code.
  • Both generators walk roots and children in document order and emit Name and Transform for every entity, followed by components in stored order.
  • Generation is pure: no file access, no globals, no timestamps. The same document and options produce identical text.
  • Errors are explicit: EmptyScene, InvalidIdentifier, NonFinite (transform, material or light value) and InvalidCustomComponent. Invalid scenes must not panic the generator.
  • The BSN generator conditionally adds asset parameters (meshes, materials) to the function signature, based on what the document uses, and chooses bsn! (single root, impl Scene) or bsn_list! (multiple roots, impl SceneList).
  • The CLI and editor are thin wrappers around generate: the CLI prints to stdout or writes -o; the editor shows a preview, can copy to the clipboard and can save to a file.

Editor shell

ForgeApp owns all long-lived state: Session, UiState, Project, AssetIndex, TextureCache, Toasts, EditorConfig, ExportState, every panel state and the current scene path. Panels never own the document.

Frame order

Each frame, ForgeApp builds a borrowed EditorContext and runs, in order:

  1. menu::show - top menu bar.
  2. shortcuts::handle - global keyboard shortcuts.
  3. status_bar::show - counts, selection, dirty state, mode, cursor position.
  4. assets_panel::show - asset browser (side panel).
  5. hierarchy::show / inspector::show - left/right side panels when visible.
  6. The central panel: viewport2d::show or viewport3d::show according to the view mode.
  7. palette::show, then dialogs::show - overlays.
  8. The collected Vec<Action> is dispatched by the app; autosave runs afterwards.

Menus, shortcuts, panels and dialogs all return actions instead of performing operations.

Context and panels

pub struct EditorContext<'a> {
    pub session: &'a mut Session,
    pub project: Option<&'a Project>,
    pub assets: &'a AssetIndex,
    pub textures: &'a mut TextureCache,
    pub ui: &'a mut UiState,
    pub toasts: &'a mut Toasts,
    pub export: &'a mut ExportState,
}
// Frozen panel entry points (types abridged; see the module sources).
hierarchy::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut HierarchyPanelState) -> Vec<Action>
inspector::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut InspectorPanelState) -> Vec<Action>
assets_panel::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut AssetsPanelState) -> Vec<Action>
viewport2d::show(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, state: &mut Viewport2dState) -> Vec<Action>
viewport3d::show(ui: &mut egui::Ui, cx: &mut EditorContext<'_>, state: &mut Viewport3dState) -> Vec<Action>
menu::show(ctx: &egui::Context, cx: &EditorContext<'_>) -> Vec<Action>
palette::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut PaletteState) -> Vec<Action>
dialogs::show(ctx: &egui::Context, cx: &mut EditorContext<'_>, state: &mut DialogsState) -> Vec<Action>
welcome::show(ctx: &egui::Context, state: &mut WelcomeState, config: &EditorConfig) -> Vec<Action>
shortcuts::handle(ctx: &egui::Context, session: &Session) -> Vec<Action>
status_bar::show(ctx: &egui::Context, cx: &EditorContext<'_>)

Rules that keep panels independent and testable:

  • Selection changes are immediate (cx.ui.selection.select_only/add/toggle/...); app-level operations become actions.
  • Scene mutations go through cx.session.edit(...) (or snapshot + commit for drags).
  • Panels never save files, spawn processes, open dialogs themselves or change the active screen.
  • Each panel owns a Default-constructible state struct stored by ForgeApp.
  • Input focus is not stolen except by the command palette and dialogs.
  • User feedback uses cx.ui.status for routine messages and cx.toasts for successes/errors.

Actions

Action (in action.rs) is the vocabulary between UI and app. It covers scene lifecycle (new, open, save, save-as), export (BSN/Rust, copy, write), editing (undo, redo, duplicate, delete, focus), view state (2D/3D, grid, snap, panels, fit), and shell operations (palette, shortcuts, about, quit, reload assets). ForgeApp::handle_actions is the only place that executes them. Adding an action is a shared-contract change: the variant must be added to action.rs and handled in app.rs, never handled inside a panel.

UI state

UiState holds multi-selection (ordered set plus a primary entity), view mode, panel visibility, grid/snap settings, per-panel search strings and asset filter, cursor world position, viewport rectangle, status text, error banner and the one-frame fit_view_requested flag. Panel-specific state (zoom, pan, drag origins, inline-rename buffer, filters, dialog fields) stays in that panel's own state struct.

Error handling and robustness

  • Core errors are thiserror enums (SceneError, ProjectError, CodegenError, ConfigError). User input, file I/O and RON parsing never use unwrap()/expect().
  • Editor failures are surfaced as toasts or an error banner and leave the previous state usable; opening a malformed scene must not panic the app.
  • CLI user errors are printed to stderr with a non-zero exit code; no panics.
  • Asset scanning and texture loading are failure-safe: missing files produce empty/placeholder states, not crashes.
  • Structural operations and validation bound parent-chain walks so malformed forests cannot hang the UI.

Testing strategy

The project separates pure logic from UI and from real-Bevy compilation so each layer can fail independently:

Layer What it proves Mechanism
Core unit tests Model, hierarchy, undo, project, config, assets, validation, both generators #[cfg(test)] modules and crates/bevy_forge_core/tests/
CLI tests Argument parsing, exit codes, file output, JSON shape std::process::Command with env!("CARGO_BIN_EXE_forge") and tempfile project fixtures
Editor tests Panels render and emit the expected actions without a window Headless egui::Context frames driving ForgeApp::new_for_test / ForgeApp::ui and panel functions
Generated-code gate Exported BSN is valid Rust against Bevy 0.20.0 compat/bsn-smoke (cargo check) plus scripts/check-generated-code.sh
Manual/visual The actual desktop app is usable and looks intentional Launch with the demo project and capture docs/screenshot.png

Representative commands (run inside a checkout):

cargo test -p bevy_forge_core
cargo test -p bevy_forge_cli
cargo test -p bevy_forge_editor
cargo check --workspace
cd compat/bsn-smoke && cargo check

Snapshot assumptions are asserted directly: tests create documents through the public structural API and check check_integrity(), round-trip scenes through RON, compare undo/redo states, and compare generated text against expected templates. The generated-code gate is the only test that links Bevy, which is why it lives in an excluded crate.

Non-goals

  • No live in-game editor plugin, remote protocol or hot reload in 0.1.
  • No Bevy dependency in the editor or core crates (and therefore no Bevy types in the scene model).
  • No runtime .bsn asset format; BSN is generated Rust source.
  • No animation editor, bevy_ui editor, material graph, terrain tools, collaboration or asset import pipeline.
  • No GPU-rendered viewport; the 3D view is an egui-painted preview.
  • No support for Bevy versions other than 0.20 BSN, and no legacy .scn.ron dynamic scenes.
  • No native file dialogs.

Extension points

  • New built-in component. Add a ComponentData variant plus its data struct, a ComponentKind entry and key, default construction, asset references, validation rules, both generator arms, the inspector's add/remove UI and tests. This is a contract change because ComponentData is frozen.
  • New primitive shape. Extend PrimitiveShape and half_extents, emit the verified Bevy constructor in the BSN/Rust generators, and add inspector fields. Verify the constructor against Bevy 0.20 before documenting it.
  • New export format. Add an ExportFormat variant, a generator module and dispatch arm, a CLI value, an editor menu entry and compile/test coverage.
  • New panel or action. Panels follow the show(...) -> Vec<Action> pattern and keep their state in a Default struct owned by ForgeApp. New actions require editing action.rs and handling them in app.rs; panels must not acquire file I/O.
  • New CLI command. Add a clap subcommand in crates/bevy_forge_cli, reuse core APIs, define exit codes, and add process tests.
  • Project-specific components. Prefer the custom-component escape hatch over adding built-ins: store a type path and raw field expressions, and document the required derives. This keeps the editor independent of game code.
  • Asset kinds. Extend AssetKind::from_path and the asset filter when a new file type needs first-class browsing.

Public signatures are frozen for this release. Changing one requires updating the contract and all call sites rather than editing a panel in isolation.

Further reading

  • Scene format - the .forge.ron reference.
  • BSN export - templates, derive requirements, Bevy integration.
  • README - quick start, CLI, shortcuts, limitations.