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.
- Product overview and quick start: README
- Scene file reference: Scene format
- Export templates: BSN export
Design constraints
- No Bevy dependency in the editor.
bevy_forge_coreandbevy_forge_editordepend only on serde/RON/IndexMap/thiserror/toml in core and eframe/egui/image in the editor. The only crate that links Bevy is the excludedcompat/bsn-smokeverification crate. This keeps editor builds fast and independent of Bevy's release cadence. - Export is source code. A
.forge.ronscene 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. - One scene format.
.forge.ron(RON via serde) is the only scene extension. It is Forge's own format, not Bevy's.scn.rondynamic-scene format. - 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.
- Immediate-mode UI with deferred actions. Panels render and return
Actionvalues. 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_idis monotonic. - Each entity has exactly one parent (
Nonefor roots). Cycles are rejected byreparent. rootsand everychildrenvector define display order and are preserved by save/load.SceneDoc::entitiesis anIndexMap; insertion order is stable for serialization.EntityNode.idmust 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() == []. lockedis 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_componentrefuses duplicate keys; custom components are unique per type path, so two different custom types can coexist.Mesh3dDataholdsMeshSource(Primitive(PrimitiveShape)orAsset(String)) andMaterialData. Primitives are cuboid, sphere, plane, cylinder and capsule.CustomComponentstores 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");
editclones the document before running the closure and records a snapshot only when the result differs.commitaccepts a caller-supplied pre-edit snapshot and records it once at the end of a drag.doc_mutis 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/redoswap snapshots and return the action label; the app puts that label in the status bar.mark_savedclears the dirty flag,replaceswaps in a new document and clears history (used when opening or creating a scene), andis_dirtydrives 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.rsowns the shared options and dispatch;codegen/bsn.rsemits Bevy 0.20 BSN andcodegen/rust.rsemits imperativeCommands::spawncode.- Both generators walk roots and children in document order and emit
NameandTransformfor 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) andInvalidCustomComponent. 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 choosesbsn!(single root,impl Scene) orbsn_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:
menu::show- top menu bar.shortcuts::handle- global keyboard shortcuts.status_bar::show- counts, selection, dirty state, mode, cursor position.assets_panel::show- asset browser (side panel).hierarchy::show/inspector::show- left/right side panels when visible.- The central panel:
viewport2d::showorviewport3d::showaccording to the view mode. palette::show, thendialogs::show- overlays.- 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 +commitfor drags). - Panels never save files, spawn processes, open dialogs themselves or change the active screen.
- Each panel owns a
Default-constructible state struct stored byForgeApp. - Input focus is not stolen except by the command palette and dialogs.
- User feedback uses
cx.ui.statusfor routine messages andcx.toastsfor 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
thiserrorenums (SceneError,ProjectError,CodegenError,ConfigError). User input, file I/O and RON parsing never useunwrap()/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
.bsnasset format; BSN is generated Rust source. - No animation editor,
bevy_uieditor, 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.rondynamic scenes. - No native file dialogs.
Extension points
- New built-in component. Add a
ComponentDatavariant plus its data struct, aComponentKindentry andkey, default construction, asset references, validation rules, both generator arms, the inspector's add/remove UI and tests. This is a contract change becauseComponentDatais frozen. - New primitive shape. Extend
PrimitiveShapeandhalf_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
ExportFormatvariant, 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 aDefaultstruct owned byForgeApp. New actions require editingaction.rsand handling them inapp.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_pathand 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.ronreference. - BSN export - templates, derive requirements, Bevy integration.
- README - quick start, CLI, shortcuts, limitations.