Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Jackdaw is a 3D level editor built with Bevy. It does brush-based geometry, material and texture management, heightmap terrain, and a human-readable scene format (.jsn). The editor is itself a Bevy plugin, so you can drop it into a project alongside your own gameplay code without a separate runtime.

We are pre-1.0. Things change. Some pieces are in active flux (PIE, plugin / dylib loading, the BSN migration), and this book tries to call out what is solid versus what is in flight.

What you can do today

  • Author levels by drawing brushes (TrenchBroom-style), carving, and applying materials.
  • Build heightmap terrain with sculpt and erosion tools.
  • Add Bevy-reflect components to entities through a picker, edit their fields, and see your custom components round- trip through save/load.
  • Run the same scene as a standalone Bevy binary, with no editor in the dependency graph.
  • Write extensions in plain Rust that plug into the editor’s operator and panel system.

Who this is for

Two audiences:

  • Bevy developers who want a level editor for their game and don’t want to glue something together themselves.
  • Editor / tooling developers who want to build on top of a pluggable Bevy editor.

If you are coming from Hammer, TrenchBroom, or Unreal’s brush workflow, the brush model will feel familiar. If you are coming from Unity or Godot, the closest analogue is the scene editor; jackdaw’s .jsn files play the role of .unity / .tscn scenes.

What this book covers

  • Getting Started: install, scaffold a project, save a scene.
  • User Guide: the panels and tools you actually click on.
  • Developer Guide: how the editor is put together, how to write custom components, how to extend the editor with your own operators and windows.
  • Reference: feature flags, configuration, file paths.
  • Open Challenges lists what we have not built yet but want to. If you came here looking for something to hack on, start there.

Where to find us

See Giving Feedback for what kinds of reports are most useful, and where to drop the RustWeek questionnaire.

If you find a missing page or an instruction that doesn’t match what the editor does, the book lives at book/ in the repo. PRs welcome.

Giving feedback

Jackdaw is in active development. Real feedback is the fastest way to make it less rough. There are two channels:

  • Discord: discord.gg/S9k2HRwc. Best for “is this thing supposed to work this way” questions, quick screenshots, and architecture discussion.
  • GitHub issues: jbuehler23/jackdaw/issues. Best for bugs, regressions, and concrete feature requests.

If you tried to do something and got stuck, or hit a behavior that didn’t match what you expected, please file it. A short issue with what you tried, what you saw, and what you expected is more useful than a polished one.

RustWeek 2026 questionnaire

If we’re at RustWeek and you’ve kicked the tires for a few minutes, the answers below help triage what to fix first. Answer what you can; skip what you can’t.

Where to send it: paste your answers into the #rustweek-feedback channel on Discord (join via discord.gg/S9k2HRwc first if you’re not already in the server). Plain text is fine; one message or several, whichever is easier.

Quick

  1. How long did it take to get from cargo install jackdaw (or “build from source”) to your first cube on screen?

    Your answer:

  2. How clear was the launcher’s New Project flow on a 1-5 scale (1 = confused, 5 = obvious)?

    Your answer:

  3. Did the picker show your custom components without you doing anything special?

    Your answer:

  4. How clear was the brush tool on 1-5?

    Your answer:

  5. Did File > Save and cargo run work as you expected?

    Your answer:

  6. Did the play-mode toggle (Play / Stop) do what you expected?

    Your answer:

  7. On a 1-5 scale, how likely are you to use jackdaw for a real game in the next 6 months?

    Your answer:

  8. Which other tools have you tried for this? (TrenchBroom, Bevy without an editor, Unity, Unreal, Godot, your own, etc.)

    Your answer:

  9. Which one would you reach for instead of jackdaw today, and why?

    Your answer:

  10. OS you tried jackdaw on (Linux, macOS, Windows)?

    Your answer:

  11. Bevy familiarity on a 1-5 scale (1 = never used, 5 = ship bevy games)?

    Your answer:

  12. What kind of game are you building (genre, scope)?

    Your answer:

  13. One thing that surprised you (positive or negative)?

    Your answer:

  14. One thing that confused you?

    Your answer:

  15. Static-template (default) or dylib path? Did you hit any build issues either way?

    Your answer:

Open-ended

  1. What was your worst pain point in the first 30 minutes?

    Your answer:

  2. Did On<Insert, T> and GlobalTransform work as you expected? If you’ve used vanilla Bevy scenes for this, how did jackdaw compare?

    Your answer:

  3. Did EditorOnly make sense as a pattern? Did you find a use for it, or did the parent/child setup feel awkward?

    Your answer:

  4. The brush tool: shift-drag to extrude faces, vertex/edge edit, slice. Anything that felt off or missing?

    Your answer:

  5. What’s the smallest change to jackdaw that would tip you into using it for a real project?

    Your answer:

  6. Anything else?

    Your answer:

Installation

You need rustup and a recent nightly. Jackdaw currently targets the toolchain pinned at the top of .github/workflows/ci.yaml (as of writing, nightly-2026-03-05). Anything close to that should work, but if you hit weird type-system errors, set:

rustup install nightly-2026-03-05
rustup default nightly-2026-03-05

Linux system deps

You’ll want the same packages bevy needs:

sudo apt install libasound2-dev libudev-dev libwayland-dev

Adjust for your package manager on other distros. macOS and Windows don’t need anything extra.

Install jackdaw

cargo install jackdaw once 0.4 ships on crates.io. Until then, build from source:

cargo install --git https://github.com/jbuehler23/jackdaw

The launcher will open. From there:

  1. Click + New Game.
  2. Pick a name and a folder. The default template is Game (static), which is the recommended path.
  3. The launcher scaffolds the project, builds a per-project editor binary, and opens it. The first build pulls all of bevy and takes a few minutes. Subsequent opens are fast.

Sanity check

Once the editor is open:

  1. Right-click in the outliner. Add > Cube. A brush appears in the viewport.
  2. File > Save. A file shows up at assets/scene.jsn.
  3. cargo run from the project folder. The standalone binary loads the same scene, no editor.

If those three steps work, you’re good. If they don’t, file an issue with what you tried and the error you saw. There’s a Giving Feedback page with more detail.

Your first scene

This page walks you from a blank project to a saved scene with one cube in it. Five minutes, give or take.

Pick a starting point

The launcher has two starting paths:

  • + New Project with the game-static template. You get a lib.rs with a MyGamePlugin, a bin/editor.rs that hosts the editor, and a main.rs that runs the standalone game. Pick this if you want to ship a real binary later.
  • + New Scene inside an already-open project. Use this if you just want to author a .jsn next to ones you have.

If you used the static template, the launcher offers to build the editor binary on first open. Say yes; subsequent opens are fast incremental rebuilds.

Place a cube

Once the editor is open:

  1. In the Hierarchy panel, right-click and pick Add > Cube.
  2. The cube appears at the origin. Click it in the viewport or in the hierarchy to select.
  3. With the cube selected, drag a translation arrow on the gizmo. The default mode is translate; press R for rotate, T for scale, Esc to return to translate. Arrow keys nudge on the grid.

That cube is a brush, not a .glb import, so you can edit its faces in place. See the Brushes chapter when you want to do that.

Save the scene

File > Save (or Ctrl+S). The first save asks where to put the file; pick assets/scene.jsn to match what the static template’s standalone binary expects.

Open that .jsn in your text editor if you want to peek. It is plain JSON-ish text, with one entry per entity and reflect component data inline. The format is documented in JSN Format.

See it run outside the editor

If you scaffolded with game-static:

cargo run

This launches the standalone binary. It loads assets/scene.jsn from disk and runs your MyGamePlugin. No editor in the loop. The cube sits where you placed it, and any components you attached in the inspector are alive on the entity.

What you have now

A project with one scene, one cube, and a save/load round trip you can iterate on. Next steps:

Migrating an existing project

If you already have a Bevy 0.18 game, you can wire jackdaw in without starting from scratch. The diff is small, but there are a few gotchas. This page walks through it.

The end state matches what cargo generate produces from the game-static template:

  • src/lib.rs holds your MyGamePlugin.
  • src/main.rs is a thin standalone runner.
  • src/bin/editor.rs is the editor + game binary.
  • Scene data lives in assets/scene.jsn.

You can read the templates directly at templates/game-static/ to compare against your project as you go.

1. Bump bevy to 0.18

If your project is on an older bevy, bump it first and get cargo run working again. Jackdaw doesn’t have a story for older versions.

2. Cargo.toml deltas

Add these lines:

[features]
default = []
editor = ["dep:jackdaw"]

[dependencies]
bevy = { version = "0.18", features = ["file_watcher"] }
jackdaw = { version = "0.4", default-features = false, optional = true }
jackdaw_runtime = "0.4"
ctrlc = "3"

[[bin]]
name = "editor"
required-features = ["editor"]

Notes:

  • bevy/file_watcher is what powers hot-reload of assets/scene.jsn in the standalone runner.
  • jackdaw is optional and gated behind editor. Without that feature your standalone game has no editor deps.
  • jackdaw_runtime is the small runtime-only crate that loads scenes from .jsn. Always present.
  • ctrlc claims SIGINT and SIGTERM before wgpu and gilrs swallow them. Without it, Ctrl+C in your terminal won’t kill the game.

If 0.4 isn’t on crates.io yet (it isn’t at the time of writing), patch to a local checkout:

[patch.crates-io]
jackdaw = { path = "/path/to/jackdaw" }
jackdaw_runtime = { path = "/path/to/jackdaw/crates/jackdaw_runtime" }

3. Move gameplay into a plugin

If everything currently lives in main.rs, that won’t fly. The editor binary needs to add its own plugins on top of yours, so your gameplay has to be reachable as a Plugin.

In src/lib.rs:

#![allow(unused)]
fn main() {
use bevy::prelude::*;
use jackdaw_runtime::prelude::*;

#[derive(Default)]
pub struct MyGamePlugin;

impl Plugin for MyGamePlugin {
    fn build(&self, app: &mut App) {
        // your systems, observers, resources
    }
}
}

Anything you used to write inline in main() after App::new() moves into build(). With one important exception, see step 5.

4. Standalone main.rs

Replace your main.rs with something close to this:

use bevy::prelude::*;
use jackdaw_runtime::prelude::*;

fn main() -> AppExit {
    let _ = ctrlc::set_handler(|| std::process::exit(130));

    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(JackdawPlugin)
        .add_plugins(your_crate::MyGamePlugin)
        .add_systems(Startup, spawn_initial_scene)
        .run()
}

fn spawn_initial_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(JackdawSceneRoot(asset_server.load("scene.jsn")));
}

JackdawPlugin is what spawns the entities listed in assets/scene.jsn. Without it your runtime has no scene.

5. Editor binary

Create src/bin/editor.rs:

use bevy::prelude::*;
use jackdaw::prelude::*;
use jackdaw::project_select::PendingAutoOpen;
use std::path::PathBuf;

fn main() -> AppExit {
    let _ = ctrlc::set_handler(|| std::process::exit(130));

    let project = std::env::var_os("JACKDAW_PROJECT")
        .map(PathBuf::from)
        .or_else(|| std::env::current_dir().ok());

    let mut app = App::new();
    app.add_plugins(DefaultPlugins)
        .add_plugins((PhysicsPlugins::default(), EnhancedInputPlugin))
        .add_plugins(EditorPlugins::default())
        .add_plugins(your_crate::MyGamePlugin);

    if let Some(root) = project.filter(|p| p.is_dir()) {
        app.insert_resource(PendingAutoOpen { path: root, skip_build: true });
    }

    app.run()
}

The important bit is adding PhysicsPlugins and EnhancedInputPlugin here, not in MyGamePlugin. Both the editor and your game need them, so if MyGamePlugin adds them too you’ll get a “plugin already added” panic.

Same applies to any other ambient plugin (AhoyPlugins, your own UI plugins, etc): they go next to DefaultPlugins, not in MyGamePlugin.

6. Move authored data into the scene

If your existing game spawns entities in code (lights, cameras, level geometry), pick the ones that should be authorable in the editor and move them out. They’ll live in assets/scene.jsn instead.

For each component you want to author in the editor, derive Reflect:

#![allow(unused)]
fn main() {
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default)]
pub struct PlayerSpawn;
}

That’s all you need. The component shows up in the editor’s Add Component picker. See Custom Components for the full story.

7. Try it

cargo run        # standalone
cargo editor     # editor + game

The editor opens, picks up your project, and you can author scene data. The standalone reads the same scene.jsn.

Common gotchas

“plugin already added” panic on cargo editor. Either MyGamePlugin is adding DefaultPlugins, PhysicsPlugins, EnhancedInputPlugin, or some other plugin the editor already added. Move it to main.rs / editor.rs.

Component doesn’t show in the picker. Probably missing #[derive(Reflect)] or #[reflect(Component)]. If both are present and it still doesn’t show, check if it has @EditorHidden somewhere (it shouldn’t, for your own types).

Scene loads but observer queries return wrong values. If you have an On<Insert, T> observer that reads GlobalTransform, it should work. The scene loader runs transform propagation inline before user component inserts. If you see weird positions, file a bug.

Standalone game crashes on scene load. Most likely your Cargo.toml has panic = "abort" and a reflected component in your scene file no longer matches its current type definition. The deserialize step returns errors cleanly, but a genuinely panicking insert will kill the process. Fix the schema drift, don’t try to swallow the panic.

Viewport navigation

The viewport uses a fly-camera scheme that should feel familiar if you have used Hammer, TrenchBroom, or Unreal’s perspective view. Right-mouse-button to look, WASD to move.

The full key list lives in Keyboard Shortcuts; this page is the plain-English version.

Look and move

Hold the right mouse button to enter look mode. While held:

  • W / A / S / D move along the view direction.
  • Q / E move down and up in world space.
  • Shift doubles speed.
  • The mouse wheel adjusts speed live, so you can scroll up while flying around to cover a level quickly.

Releasing RMB drops you back into normal cursor mode.

Dolly without entering look mode

If you don’t want to lift your hand, scrolling without RMB held dollies the camera forward and back along the look axis. Useful for small framing tweaks while a tool is active.

Focus selection

Press F with one or more entities selected to recenter the camera on the selection bounds. The camera keeps its current yaw and pitch; only translation changes. Good for when you have flown off into the void and need to come back.

Camera bookmarks

The viewport has nine bookmark slots:

  • Ctrl+1 through Ctrl+9 saves the current camera pose to a slot.
  • 1 through 9 restores it.

Bookmarks are session-only right now. They live in an in-memory CameraBookmarks resource and reset on editor restart. Persisting them into the project file is on the list; not done yet.

View modes and the grid

  • Ctrl+Shift+W toggles wireframe.
  • [ and ] step the grid size down and up. Numbers print in the status bar.
  • Ctrl+Alt+Scroll is the same step, mouse-driven.

The grid size also drives the snap distance for translate operations, so changing it doesn’t just affect the visuals.

Mouse look feels off

If the viewport rotates faster or slower than you expect, that is the bevy_enhanced_input mouse sensitivity, not a jackdaw setting. We don’t expose it in the UI yet (see Open Challenges); file an issue if it’s blocking you and we’ll surface it.

Brushes

A brush is jackdaw’s primitive for level geometry. Think TrenchBroom or old-school Hammer: convex polyhedra defined by their faces, with per-face materials and UVs, edited in place without a DCC tool. They serialize directly into the scene .jsn, no external mesh files.

The two ways to make a brush

Quick add

Hierarchy panel, right-click, Add > Cube or Add > Sphere. You get a unit primitive at the origin, selected and ready to move. This is the fastest path when you just need a block.

Draw

Press B to enter the draw-brush modal. Click in the viewport to drop vertices, then press Enter to close the polygon and extrude it to a brush. While drawing:

  • Click places a vertex.
  • Backspace removes the last vertex.
  • Enter closes the polygon.
  • Esc or right-click cancels.
  • Tab toggles between additive and subtractive draw mode. In subtractive mode (C to enter directly), the closed polygon CSGs out of the brush you draw against.

The plane you draw on is the closest face under your cursor, or the world floor if nothing is under it.

Editing a brush

Select a brush, then pick the edit mode:

  • 1 vertex mode
  • 2 edge mode
  • 3 face mode
  • 4 clip mode

Click an element to select it, drag the gizmo to move it. Multi-select with Shift+Click. Delete removes the selected element (vertices collapse the surrounding face, faces leave a hole jackdaw won’t render).

Esc exits edit mode and returns to entity-level selection.

Snap and constrain

  • Ctrl while dragging toggles snap to grid; the snap step follows your current grid size.
  • X / Y / Z constrain the drag to that axis.
  • MMB toggles the global snap mode without holding Ctrl.

Clip

Clip mode (4) draws a plane through the brush. Drag the plane gizmo where you want the cut, press Enter to apply. The brush splits in two; the clipped-off side becomes a new brush you can immediately delete or move.

Boolean operations

Select two or more brushes and run one of:

  • CSG Subtract (Ctrl+K): cut the second selection out of the first.
  • CSG Intersect (Ctrl+Shift+K): keep only the volume both brushes share.
  • Join (Convex Merge) (J): merge two brushes back into one convex brush, when their union is itself convex.

All three live under the Edit menu. They run through the CSG code in crates/jackdaw_geometry. The result replaces the inputs with new brushes; the original selection ordering picks which is the minuend in subtract.

Faces, materials, and UVs

Selecting a face in face mode (3) shows its material and UV controls in the inspector. You can:

  • Set a texture or material from the material browser.
  • Tweak UV offset, scale, and rotation per face.

Face data lives on the brush entity as BrushFaceData. See Materials & Textures for what the material picker exposes.

Common gotchas

  • Brush disappears after a CSG op. The op produced a degenerate result (zero-volume intersection, fully consumed subtractor). Undo and try a different overlap.
  • Faces look inside-out. Brushes assume outward normals. If you authored vertices in clockwise order while drawing, flip the brush via the inspector or redraw.
  • Snap is “wrong”. Snap follows the grid size shown in the status bar, not a fixed unit. Step the grid with [ and ].

Materials and textures

Two panels handle this: the Asset Browser and the Material Browser. Earlier builds had a separate texture browser, but it was absorbed into the asset browser and only the two remain.

Asset browser

The bottom-left panel by default. Shows the project’s assets/ directory as a tree on the left and a tile grid of the current folder on the right. Image files (png, jpg, jpeg, bmp, tga, webp, ktx2) render as thumbnails; everything else shows a generic file tile.

What you do here:

  • Click an image to preview it in the side panel (KTX2 arrays show a layer slider).
  • Drag an image tile onto a brush face in the viewport to apply it as the face’s texture_path. This routes through the ApplyTextureOp operator, so it goes on the undo stack.
  • Drag a .glb into the viewport to spawn a model entity.
  • Drag a .jsn to open it.
  • Drop new files into assets/ from your file manager. Bevy’s file_watcher is on in the templates, so they show up without a manual refresh.

If you only need a texture and no PBR parameters, this is the path. The “texture browser” that older docs and tutorials mention is just this panel filtered to images.

Material browser

A sibling panel for named PBR materials: bundles of textures plus material parameters (metallic, roughness, normal strength, parallax) registered in the MaterialRegistry resource. Use this when one texture isn’t enough, or when you want to share material settings across many brushes.

Auto-detection

If you drop a folder of textures named consistently (e.g. brick_albedo.png, brick_normal.png, brick_roughness.png), the material browser groups them into one auto-detected entry. The regex driving detection is pbr_filename_regex in src/material_browser.rs; it recognises common suffixes (_albedo, _diffuse, _normal, _n, _roughness, _r, _metallic, _m, _ao, _height, _displacement).

You can edit the resulting material in the inspector. Material values serialize into the scene’s JsnAssets table (or the project-wide catalog) keyed by bevy_pbr::StandardMaterial and ride along with save.

Applying

Select a brush face, drop a material onto it. The face’s material_name field takes priority over its texture_path, so a face with both falls back gracefully if the material is missing.

Preview

Each definition renders onto a sphere via a render-to-texture pipeline (src/material_preview.rs). Previews use RenderLayers::layer(1) so they don’t clash with main-view geometry.

Project-wide vs scene-local materials

Two storage tiers:

  • Scene-local: the material lives only inside the current .jsn. References use #Name.
  • Project-wide: it lives in .jsn/catalog.jsn (legacy fallback assets/catalog.jsn) and any scene in the project can reference it. References use @Name.

The browser shows both, with the source labelled.

Common gotchas

  • Texture didn’t show up after I dropped it in. Bevy’s watcher catches new files but only existing scenes reload their materials. Re-select the brush face to refresh.
  • The auto-detect groups two unrelated textures. Filename heuristics are coarse. Rename the files or open the affected definition and split it manually.
  • Material disappears in the standalone build. Standalone loads assets/scene.jsn plus .jsn/catalog.jsn. Scene-local materials still ship inline; project references resolve from the catalog at load time, so a missing catalog file causes @Name references to fall back to defaults.

Terrain

Jackdaw’s terrain is a heightmap-backed mesh, chunked for streaming and edited with brush-style sculpt tools. The crate that does the work is jackdaw_terrain; if you want the actual data structures, the entry points are Heightmap, apply_brush, and build_chunk_mesh_data.

Add a terrain

Add > Terrain in the hierarchy. You get a flat heightmap component on a new entity, with a chunked mesh underneath. Resolution and physical size are properties on the Terrain component, editable in the inspector.

Sculpt

Select the terrain, then pick a sculpt tool from the toolbar or the terrain panel. Available tools:

  • Raise / lower. Add or subtract height under the cursor.
  • Flatten. Drag heights toward the height under the click point.
  • Smooth. Average heights inside the brush radius.
  • Noise. Add procedural noise inside the brush radius; good for breaking up flat areas without sculpting by hand.

Brush radius and strength sit in the toolbar. The brush preview ring tracks the cursor so you can aim before committing.

Ctrl+Z undoes the last stroke. Each contiguous drag is one undo entry, not one entry per heightmap sample.

Erosion

The erosion pass simulates hydraulic erosion across the whole heightmap. Adjust iteration count, evaporation rate, and sediment capacity in the panel; click Run. It is a one-shot operation, not a real-time tool.

This is the slowest thing in the terrain workflow, since it runs on the CPU and rebuilds every chunk mesh when it finishes. Save before you click. We do not have a cancel button yet.

Texture painting

Texture painting on terrain is on the roadmap but not built. Today the terrain takes a single material applied to the whole surface. If you need varied surface textures, blend in your fragment shader or split the heightmap into multiple terrains, each with its own material.

Chunking

Chunks are 32 cells per edge (src/terrain/mod.rs::CHUNK_SIZE). Edits only rebuild the chunks that overlap the brush, which is what keeps sculpting fast on large heightmaps. There is no LOD or frustum streaming yet; every chunk renders at full resolution.

Common gotchas

  • Mesh shows seams between chunks. Normals are computed per chunk. The boundary samples should match across chunks; if they don’t, an edit straddled the boundary and one side never rebuilt. Touch both sides with the smooth tool to force the rebuild.
  • Erosion result looks wrong. Iteration count is the knob to tune first. Defaults aim for a generic mountain; rolling hills want fewer iterations and a higher evaporation rate.
  • Standalone game shows no terrain. jackdaw_runtime doesn’t pull in jackdaw_terrain. If your game needs terrain at runtime, add jackdaw_terrain to your standalone Cargo.toml and bring whatever plugin / systems you want into your game’s plugin alongside JackdawPlugin.

Physics and prop placement

Jackdaw uses avian3d for physics. There’s no global “enable physics” toggle: you opt an entity in by adding the components it needs, and the editor’s Physics Tool lets you drop dynamic bodies into the scene hammer-style.

Adding physics to a brush or entity

A physics-enabled entity needs two things:

  • AvianCollider: jackdaw’s wrapper around avian’s ColliderConstructor. Picks the collider shape (cuboid, sphere, trimesh-from-mesh, etc.) and rebuilds the actual Collider whenever you change it.
  • RigidBody: dynamic / static / kinematic. Dynamic bodies fall under gravity; static bodies are immovable collision surfaces.

Workflow:

  1. Select the brush (or any entity with a mesh).
  2. Inspector panel: click + Add Component.
  3. Search “AvianCollider” and pick it (it lives under the Avian3d category).
  4. Picking AvianCollider auto-adds RigidBody via the require chain. Default body is Dynamic.

The collider builds from the entity’s geometry on the next tick. For brushes, jackdaw triangulates the brush faces and hands them to avian. For mesh entities (Mesh3d), avian reads the loaded mesh asset.

You’ll see the green wireframe overlay on the brush once the collider is up. If you don’t, the collider build failed silently; check the brush has finite volume.

Switching collider shape

AvianCollider is a single-field tuple struct holding a ColliderConstructor. The inspector renders it as an enum dropdown. Common picks:

  • Cuboid / Sphere / Capsule / Cylinder / Cone: primitive shapes, parameters are the half-extents / radii.
  • TrimeshFromMesh: builds a triangle mesh collider from the entity’s mesh. Best for brushes and detailed props where shape fidelity matters; expensive for rigid bodies.
  • ConvexDecompositionFromMesh: V-HACD decomposes the mesh into convex hulls. Use for dynamic props where trimesh isn’t valid.

Trimesh colliders are static-only in practice (avian rejects trimesh colliders on dynamic bodies). For dynamic props, use a primitive or convex decomposition.

Static level geometry

For platforms, walls, and floors: set RigidBody to Static in the inspector. Static bodies don’t fall, can’t be moved by forces, and serve as the collision surface other bodies land on.

Default is Dynamic; switch to Static after adding the bundle if the brush is meant to be level geometry.

Physics Tool: dropping props into place

Once entities have colliders, you’d usually want to author their resting positions by simulating instead of guessing poses. That’s what the Physics Tool is for.

Workflow

  1. Select the props you want to place (one or many).
  2. Press Shift+P to enter the Physics Tool.
  3. The status bar reads Physics Tool | drag selected to release | Space commit | Esc cancel.
  4. Click and drag a selected entity. Release. Gravity takes over and the body falls / settles.
  5. Drag again to nudge.
  6. Press Space to commit and exit. Settled positions are pushed onto the undo stack as a single entry, so Ctrl+Z returns you to physics mode at those positions for another pass.
  7. Esc instead of Space cancels and reverts to the pre-tool poses.

Selected vs non-selected

The tool only simulates the selected entities. Every other dynamic / kinematic body in the scene gets paused (RigidBodyDisabled) so it acts as a static obstacle while the selection settles. Static bodies are always solid; the tool never disables them.

This is the key UX: select what you want to place, ignore the rest, drop them in. Re-select a different group to place that one without disturbing the first.

Visual cues

  • Green wireframe: collider visible while a body is around.
  • Orange: collider visible on a selected body.
  • Cyan / blue: a sensor.
  • Hierarchy arrows (toggle in View menu): show the body to collider parent / child links.

Common gotchas

  • Dynamic body falls through the floor. The floor isn’t a static body, or the floor entity has no collider. Add AvianCollider to the floor and set its RigidBody to Static.
  • Collider wireframe is the wrong shape after rescaling. Should track scale gizmo edits since the avian-integration fix in this branch. If you still see drift, file an issue with the collider type and the resize gesture.
  • ColliderConstructor panic when added directly. Picking the raw ColliderConstructor (not AvianCollider) on an entity without a Mesh3d panics avian’s auto-init. The picker hides standalone ColliderConstructor for this reason; pick AvianCollider instead.
  • Body can’t be selected in physics mode. Selection works the same as Object mode (LMB-click). If clicks land on the wrong body, check the cursor is over the body’s collider, not just its visual mesh.
  • Body doesn’t move when I drag. The first drag in a physics session unpauses Time<Physics>; if your drag is too short to clear the threshold, the sim never starts. Drag a few pixels.

Scene management

A “scene” in jackdaw is one .jsn file. A “project” is a folder with a .jsn/project.jsn config file (legacy projects keep it at the root), an assets/ directory, and a Cargo.toml if you scaffolded with the static template. Scenes live under assets/.

Save and load

  • Ctrl+S saves the current scene to its on-disk path. The first save prompts for a path; pick something under assets/.
  • Ctrl+O opens a scene from disk. The picker starts in the current project’s assets/ folder.
  • Ctrl+Shift+N creates a new empty scene in memory; it is unsaved until you Ctrl+S it.

The format details are in JSN Format. The short version: human-readable, line-diffable, and designed to read in git diff without making you cry.

Project select screen

The launcher (AppState::ProjectSelect) is the first thing you see when you run jackdaw with no arguments. It shows:

  • Recent projects, with timestamp and last-opened scene.
  • A + New Project button for the scaffolds.
  • A + New Extension and + New Game button if you want to start an extension or game crate.

Recent projects with missing folders are filtered out (we fixed this in #222). Click a project to open it; the editor transitions into AppState::Editor and reads the project’s default scene.

Default scene per project

.jsn/project.jsn carries a default_scene field. When set, the editor opens that scene automatically when you load the project. If unset, the launcher tries assets/scene.jsn as the convention; if that’s missing too, you start in an empty viewport and Ctrl+O from there.

You can change the default from the file menu or by editing the file directly. The editor watches the file, so external edits show up without a restart.

Multi-scene projects

Nothing stops you from putting many .jsn files in assets/scenes/. The editor doesn’t currently have a “scene list” panel, so you switch between them via File > Open.

If you reference one scene from another (sub-scenes, prefabs), that pattern is not built yet. Today scenes are flat. See Open Challenges for what scene-as-asset would look like.

Project files outside assets/

The editor only watches assets/. Code lives next to it (src/, bin/), and Bevy’s runtime asset path points at assets/. If you put a .jsn somewhere else, jackdaw can load it with File > Open, but the standalone binary won’t find it via Bevy’s asset server.

Common gotchas

  • Scene loaded but the viewport is empty. Camera might be inside geometry. Press F with nothing selected (or with a known-visible entity selected) to reframe.
  • File > Save greys out. No scene is open. Either File > New Scene or open one from the launcher.
  • Saved file has a weird path. First save from a “New Scene” defaults to the project’s assets/scene.jsn. If you want a different path, use File > Save As.

Keyboard shortcuts

KeyAction
RMB + DragLook around
WASDMove (forward / left / back / right)
Q / EMove up / down
ShiftDouble speed
ScrollDolly forward / back
RMB + ScrollAdjust move speed
FFocus selected
Ctrl+1-9Save camera bookmark
1-9Restore camera bookmark

Selection

KeyAction
LMBSelect entity
Ctrl+ClickToggle multi-select
Shift+LMB DragBox select

Transform

KeyAction
EscTranslate mode
RRotate mode
TScale mode
XToggle local / world space
MMBToggle snap
Ctrl (during drag)Toggle snap
ArrowsNudge (grid-unit move)
Alt+Arrows90 deg rotate
PageUp / PageDownNudge vertical

Entity

KeyAction
Delete / BackspaceDelete selected
Ctrl+DDuplicate
Ctrl+CCopy components
Ctrl+VPaste components
HToggle visibility
Alt+GReset position
Alt+RReset rotation
Alt+SReset scale

Brush Editing

KeyAction
1Vertex mode
2Edge mode
3Face mode
4Clip mode
X / Y / ZConstrain axis
Shift+ClickMulti-select
DeleteDelete selected element
PageUp/PageDownNudge selected vertices/edges/faces up/down
EnterApply clip plane
EscExit brush edit

Brush Draw

KeyAction
BDraw brush (add)
CDraw brush (cut)
TabToggle add / cut mode
ClickPlace vertex / advance phase
EnterClose polygon
BackspaceRemove last vertex
Esc / Right-clickCancel drawing

View

KeyAction
Ctrl+Shift+WToggle wireframe
[Decrease grid size
]Increase grid size
Ctrl+Alt+ScrollChange grid size

File

KeyAction
Ctrl+SSave scene
Ctrl+OOpen scene
Ctrl+Shift+NNew scene
Ctrl+ZUndo
Ctrl+Shift+ZRedo

Architecture

Jackdaw is a Bevy 0.18 plugin set. The editor and the standalone runtime share the same scene format (JSN) and the same component reflection. There’s no separate engine; if you can write a Bevy plugin, you can write a jackdaw extension.

Plugin structure

The editor is delivered as EditorPlugins, a Bevy PluginGroup. A typical editor binary looks like:

#![allow(unused)]
fn main() {
App::new()
    .add_plugins(DefaultPlugins)
    .add_plugins((PhysicsPlugins::default(), EnhancedInputPlugin))
    .add_plugins(EditorPlugins::default())
    .add_plugins(your_crate::MyGamePlugin)
    .run()
}

EditorPlugins pulls in everything jackdaw needs: the launcher, viewport, hierarchy, inspector, brush tools, asset browser, scene IO, and the extension loader. Your MyGamePlugin is added on top of that.

The same MyGamePlugin is used by the standalone runtime. The standalone version doesn’t add EditorPlugins; it adds JackdawPlugin from jackdaw_runtime, which is a much smaller plugin that knows how to load .jsn scenes but doesn’t include any UI.

App states

The launcher and the editor are the same binary. The state machine is:

  • AppState::ProjectSelect is the launcher screen. Recent projects, new project, open existing.
  • AppState::Editor is the editor proper. Once you pick a project, you stay here for the session.

You can read the transitions in src/lib.rs and src/project_select.rs.

Per-project editor binary

When you scaffold a project from the launcher, the project gets its own editor binary at src/bin/editor.rs. This is what cargo editor runs. The editor binary statically links both EditorPlugins and your MyGamePlugin, which means your gameplay code is always available to the editor’s reflection without any dynamic loading.

There’s an experimental dylib path that loads game code as a hot-reloadable .so instead, but it’s off by default. Static is the recommended path. See Open Challenges for the dylib story.

Scene format

Scenes are stored in assets/scene.jsn. The format is JSON; each entity is a list of reflected components keyed by type path. The serializer skips types tagged with @EditorHidden, the entity-level EditorHidden marker, NonSerializable, and EditorOnly.

The runtime loader processes JSN entities in topological order (parents before children) and bundles Transform, Visibility, GlobalTransform, InheritedVisibility, and ChildOf into a single world.spawn per entity. User components go in afterwards, so On<Insert, T> observers see correct hierarchy-derived state. The relevant code is at crates/jackdaw_runtime/src/lib.rs::spawn_scene_entities.

JSN is the current format. The plan is to swap to BSN (Cart’s Bevy 0.19 scene-document work) once that lands; jackdaw’s JSN-first refactor was deliberately shaped to make that swap mechanical. See Open Challenges.

Brushes

Brushes are jackdaw’s CSG primitives, used for level geometry. The data lives on the brush entity as a Brush component (faces: Vec<BrushFaceData>, where each face carries a plane, texture, material, and per-face UVs). Each face becomes a child entity with a generated mesh; those children carry EditorHidden and NonSerializable so they don’t show in the outliner and aren’t saved (they’re rebuilt from the parent’s Brush data on load).

Code:

  • src/brush/mod.rs is the resource and component layer.
  • src/brush/mesh.rs rebuilds face meshes when the brush changes.
  • src/brush/interaction.rs is the editing state machine (face drag, vertex drag, edge drag).

Inspector and picker

The inspector is modular. Each component type renders through a display function that walks its reflected fields. The picker that shows on + Add Component enumerates the type registry, filters out anything tagged @EditorHidden, and sorts by category.

Code:

  • src/inspector/mod.rs is the dispatcher.
  • src/inspector/component_picker.rs is the + Add Component flow.
  • src/inspector/reflect_fields.rs renders primitive fields.

Extensions

The editor can be extended by writing a separate crate, building it as a dylib, and dropping the .so in the editor’s extensions folder. Extensions can register operators, windows, menu entries, and keybinds. See Extending the Editor for the full story.

The extension loader is crates/jackdaw_loader. The proxy dylib that extensions link against is crates/jackdaw_sdk. The rustc wrapper at crates/jackdaw_rustc_wrapper rewrites --extern bevy=... so extensions and the editor share one compiled copy of bevy types.

What’s not here yet

The architecture page doesn’t try to cover every system. The big unfinished pieces (BSN migration, full PIE, dylib loading on Windows, animation graph, asset processing pipeline) live in Open Challenges. The Crate Structure page lists the workspace crates and their roles.

Crate structure

Jackdaw is a workspace with one editor binary, a handful of runtime / format crates that user games depend on, and a larger group of internal-only crates that the editor consumes. The split exists so a shipped game pulls in only what it needs.

What a user game depends on

Three crates, no editor in the dependency graph:

  • jackdaw_jsn: the .jsn format, types, loader, and the Bevy plugin that wires the loader into Bevy’s asset pipeline. Everyone needs this.
  • jackdaw_runtime: the standalone scene loader, plus the EditorMeta / ReflectEditorMeta reflect attributes (EditorCategory, EditorDescription, EditorHidden) that user game crates use on their components.
  • jackdaw_geometry: brush data structures (BrushFaceData, CSG, triangulation). Needed at runtime because the standalone game has to rebuild brush meshes from the serialized planes.

game-static template’s Cargo.toml shows the canonical shape.

What the editor adds on top

The jackdaw crate (top-level) is the editor binary plus the plugin group EditorPlugins. It depends on every other crate in the workspace. The interesting layers:

  • jackdaw_feathers / jackdaw_widgets / jackdaw_panels: the UI layer. Feathers is the styled-widget primitives, widgets are the higher-level pieces (split panels, dock, picker), panels is the docking system.
  • jackdaw_camera: viewport camera plugin (fly camera, orbit, bookmarks). Standalone games can use it too, since it doesn’t depend on anything editor-specific.
  • jackdaw_commands: the undo/redo command stack. Editor operations push EditorCommands here.
  • jackdaw_terrain: heightmap data + sculpt + erosion.
  • jackdaw_avian_integration: physics overlays and the Physics tool. Glue between the editor and Avian.
  • jackdaw_animation: animation graph editing, clip authoring.
  • jackdaw_node_graph: node-graph primitives shared between the animation editor and the (planned) signal editor.
  • jackdaw_remote: the Bevy Remote Protocol (BRP) client used by the remote inspector when talking to a running game.

Extension and dylib plumbing

Seven crates exist for the extension story:

  • jackdaw_api: the public surface third-party extensions link against. Re-exports bevy plus the operator / extension traits. Has a dynamic_linking feature that flips bevy to its dylib build.
  • jackdaw_api_internal: host-side plumbing (loader plugin, catalog, enable/disable helpers, internal markers). jackdaw_api deliberately does not re-export this.
  • jackdaw_api_macros: proc-macros backing the extension API.
  • jackdaw_sdk: the proxy dylib that scaffolded extension projects link against via --extern bevy=libjackdaw_sdk.so. Carries the one compiled copy of bevy + jackdaw types both sides share.
  • jackdaw_dylib: the dynamic-loader shim that dlopens extension dylibs at runtime.
  • jackdaw_loader: the host-side resource that tracks loaded dylibs.
  • jackdaw_rustc_wrapper: the rustc interceptor crate. Ships its jackdaw-rustc-wrapper binary, which scaffolded dylib projects invoke through .cargo/config.toml to inject the right --extern flags.

Other crates

  • jackdaw_fuzzy: fuzzy-match scoring for the picker / command palette. Tiny.
  • jackdaw_widgets: project-specific widgets that don’t belong in jackdaw_feathers (history view, color picker, etc.).

How to find things

If you are looking for a specific feature: search the editor crate first (src/). If you find a Plugin, follow its imports back to the crate that owns the underlying logic. The editor crate is mostly orchestration; real work lives in the workspace crates.

What needs splitting

src/ is over 100 files. The brush, animation, and remote inspector subsystems are the obvious candidates for extraction into their own crates. Not blocking on it.

Custom components

Anything you can #[derive(Reflect)] can show up in the editor’s Add Component picker. There’s no separate registration step and no jackdaw-specific macro.

Minimum

#![allow(unused)]
fn main() {
use bevy::prelude::*;

#[derive(Component, Reflect, Default)]
#[reflect(Component, Default)]
pub struct PlayerSpawn;
}

That’s it. Compile, restart the editor, open the inspector on an entity, click + Add Component, type PlayerSpawn. It shows up.

A few things make this work without ceremony:

  • Bevy 0.18’s reflect_auto_register finds and registers the type at app build, so you don’t need app.register_type::<PlayerSpawn>().
  • The reflect_documentation cargo feature is on workspace-wide, so doc comments on the type become picker tooltips.
  • Jackdaw can construct a default-valued instance from primitive field defaults, so you don’t strictly need Default. Adding it is just nicer.

Categories and tooltip overrides

#![allow(unused)]
fn main() {
use jackdaw_runtime::prelude::*;

/// Spawns the player at this entity's world transform.
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default, @EditorCategory::new("Actor"))]
pub struct PlayerSpawn;
}

The picker groups PlayerSpawn under “Actor”. The doc comment above the struct becomes the tooltip. If you want a tooltip that’s different from the doc comment (for example, the doc comment is for rustdoc readers and the tooltip is for level designers), use @EditorDescription:

#![allow(unused)]
fn main() {
#[reflect(
    Component,
    Default,
    @EditorCategory::new("Actor"),
    @EditorDescription::new("Where the player respawns."),
)]
pub struct PlayerSpawn;
}

Hiding a component from the picker

Sometimes a component is part of your plugin’s internal plumbing and shouldn’t be authorable from the inspector. @EditorHidden on the type drops it from the picker but keeps the type registered for serialization:

#![allow(unused)]
fn main() {
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default, @EditorHidden)]
pub struct PlayerInternalState {
    pub spawn_count: u32,
}
}

EditorHidden does double duty: as a reflect attribute on a type (hides from picker), and as a Bevy Component on an entity (hides the entity from the outliner). Same name, two roles.

Reacting to scene-loaded components

Use a normal On<Insert, T> observer:

#![allow(unused)]
fn main() {
fn spawn_player(
    trigger: On<Insert, PlayerSpawn>,
    transforms: Query<&GlobalTransform>,
    mut commands: Commands,
) {
    let Ok(gt) = transforms.get(trigger.entity) else { return };
    commands.spawn((
        ChildOf(trigger.entity),
        // ... your player rig at gt's world position
    ));
}
}

GlobalTransform is correct here, even when the entity is loading from .jsn. The scene loader propagates transforms inline before firing observers, so you get the entity’s true world-space pose. You don’t need On<SceneInstanceReady> or the recursive-walk pattern from vanilla Bevy.

Register the observer in your plugin:

#![allow(unused)]
fn main() {
impl Plugin for MyGamePlugin {
    fn build(&self, app: &mut App) {
        app.add_observer(spawn_player);
    }
}
}

Editor-only visuals

Sometimes you want a visual indicator at a spawn point that’s visible while authoring but absent from the shipped game. EditorOnly is the marker:

#![allow(unused)]
fn main() {
fn spawn_player(
    trigger: On<Insert, PlayerSpawn>,
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        ChildOf(trigger.entity),
        EditorOnly,
        Transform::default(),
        Mesh3d(meshes.add(Cuboid::new(0.4, 0.4, 0.4))),
        MeshMaterial3d(materials.add(StandardMaterial {
            base_color: Color::srgb(1.0, 0.2, 0.2),
            unlit: true,
            ..default()
        })),
    ));
}
}

The red cube renders in the editor. When the user saves, the cube is skipped from assets/scene.jsn. The shipped game never sees it.

You can also do this entirely in the editor without code: make a brush, set it as a child of an empty that holds your component, then add EditorOnly to the brush from the inspector. The empty + your component ships, the brush doesn’t.

EditorOnly skips the whole entity from save, so don’t put it on the same entity as your gameplay marker. The pattern is always parent (gameplay component) plus child (editor visual with EditorOnly).

Common gotchas

Component doesn’t appear in the picker. Almost always one of:

  • Missing #[derive(Reflect)].
  • Missing #[reflect(Component)].
  • Has @EditorHidden somewhere (intentional or pasted from a template).
  • The crate that defines the component isn’t loaded yet (if you’re using the dylib path). Restart the editor.

Doc comment doesn’t show as tooltip. The reflect_documentation feature has to be on for the type’s own crate. The workspace Cargo.toml enables it by default. If you have your own bevy override, make sure reflect_documentation is in the feature list.

On<Insert, T> runs but the entity has the wrong GlobalTransform. Shouldn’t happen in current jackdaw. If it does, file a bug. Older versions of jackdaw needed an On<SceneInstanceReady> walk; that’s gone now.

Scene fails to load with a panic. Probably your Cargo.toml has panic = "abort" and a reflected component in your scene file no longer matches its current type definition (you renamed a field, changed a type, etc). The deserialize step returns errors cleanly, but a genuinely panicking insert kills the process. Fix the schema drift in the scene file or the type. Jackdaw used to swallow these panics with catch_unwind; it doesn’t anymore, because that was hiding real bugs.

JSN format

JSN (“Jackdaw Scene Notation”) is the on-disk format for scenes, projects, and the asset catalog. It is JSON with a fixed schema, designed to be human-readable, line-diffable in git, and to round-trip through Bevy’s reflect system without losing information.

The types live in crates/jackdaw_jsn/src/format.rs. Source of truth for the field list is the structs there; this page is the orientation.

Three file kinds

  • *.jsn (scene): one entity tree. Authored in the editor, loaded by the standalone runtime.
  • .jsn/project.jsn (project config, legacy fallback at <root>/project.jsn): default scene, persisted dock layout, project metadata.
  • .jsn/catalog.jsn (project-wide assets, legacy fallback at assets/catalog.jsn): named materials, prefab-ish assets shared across scenes in the project.

All three start with the same header struct (format_version, editor_version, bevy_version) so loaders can route on it.

Scene shape

A scene file is one JsnScene:

{
  "jsn":     { "format_version": [3, 0, 0], "editor_version": "0.4.0", "bevy_version": "0.18" },
  "metadata":{ "name": "Level 1", "author": "you", "created": "...", "modified": "..." },
  "assets":  { /* per-type-path tables */ },
  "editor":  null,
  "scene":   [ /* entities */ ]
}

Entities

Each entry in scene is a JsnEntity:

{
  "parent": 0,
  "components": {
    "bevy_transform::components::transform::Transform": { "translation": [...], "rotation": [...], "scale": [...] },
    "bevy_ecs::name::Name": "Player",
    "my_game::SpinningCube": { "speed": 1.5 }
  }
}

parent is the index of another entity in the same scene array, or absent for roots. Component values are reflect- serialized: whatever Bevy’s reflect produces for that type. Component keys are full type paths (the same string the inspector shows under “type path”).

The order of entries matters for parent / child resolution (parents must come before children), but is otherwise just authoring order.

Asset references

assets is a two-level map: type path, then named asset.

"assets": {
  "bevy_pbr::StandardMaterial": {
    "BrickWall": { "base_color": [0.6, 0.3, 0.2, 1.0] }
  }
}

Components reference these by name. The convention is:

  • #Name for a scene-local asset (must exist in the same file’s assets table).
  • @Name for a project-wide asset (resolved from catalog.jsn).

The serializer resolves the prefix at load time. If neither file has the named asset, the component falls back to its default value and the loader logs a warning.

Project file

project.jsn looks like:

{
  "jsn":     { "format_version": [3, 0, 0], ... },
  "project": {
    "name": "My Game",
    "description": "",
    "default_scene": "assets/scene.jsn",
    "layout": { /* opaque LayoutState blob, owned by jackdaw_panels */ }
  }
}

default_scene is relative to the project root, so it works when the user moves the folder around. layout is parsed as jackdaw_panels::LayoutState and is intentionally opaque to the JSN crate (so layout schema changes don’t ripple into format versioning).

Catalog file

catalog.jsn:

{
  "jsn":    { "format_version": [3, 0, 0], ... },
  "assets": { /* same shape as JsnScene.assets */ }
}

Just the assets table at the top level, plus the header.

Versioning

format_version is a [major, minor, patch] triple. The loader has a hand-written migration from v2 (the format before brush data lived in components) to v3. v1 is no longer supported. New format changes will go through the same path: a JsnSceneVN deserializer plus a migrate_to_v(N+1) method, called inside the loader before handing the scene to the rest of the editor.

The migration is intentionally one-way. We do not keep older versions on the read path; once a file has been opened and saved by a newer editor, it is in the new shape.

What is not in JSN

  • Mesh data. Brushes serialize as their face planes; the mesh rebuilds from those at load. .glb imports reference the .glb file path, not its contents.
  • Textures. Same; references only.
  • Editor-internal entities. Brush face entities, gizmo helpers, picker panels, etc., carry an EditorOnly or NonSerializable marker that the saver skips.

Extending the editor

Jackdaw extensions are plain Rust crates that you write using bevy-native APIs. Scaffolding, building, and installing are all driven from inside the editor. No custom build scripts, no cargo gymnastics, no hash-matching games.

Prerequisite: install Bevy CLI

Templates are distributed via Bevy CLI, so install that once:

cargo install --locked --git https://github.com/TheBevyFlock/bevy_cli bevy_cli

Jackdaw shells out to bevy new when you create a new project, so this needs to be on your PATH.

Author workflow

1. Launch the editor

cargo run --features dylib

The launcher (project picker) opens. On first run, the Recent Projects list is empty.

2. Create an extension

Click + New Extension on the launcher. Fill in:

  • Name: the crate name for your extension (e.g. my_tool).
  • Location: parent directory the project will be created under. The Browse button opens a folder picker.

Click Create. Jackdaw invokes bevy new -t https://github.com/jbuehler23/jackdaw_template_extension --yes <name> in the chosen location, then opens the newly-scaffolded project.

3. Edit and build

Edit my_tool/src/lib.rs in your preferred editor. Then, inside jackdaw: File > Extensions > Build from project folder, and pick my_tool/. Jackdaw runs cargo rustc with the right --extern flags and live-loads the resulting dylib. Windows, operators, and menu entries activate immediately.

Iterate: edit code, click Build from project folder again, see the changes.

Creating a game

Click + New Game on the launcher instead. Same scaffold flow with the jackdaw_template_game template. Until Play-in-Editor (PIE) lands, games load as extensions so you can start prototyping against the editor.

How it works

Jackdaw ships a tiny proxy crate (jackdaw_sdk) whose dylib carries the one compiled copy of bevy + jackdaw types that both the editor and every extension link against. When jackdaw builds an extension it invokes cargo rustc with explicit --extern bevy=libjackdaw_sdk.so and --extern jackdaw_api=libjackdaw_sdk.so flags. Your extension’s code writes plain use bevy::prelude::*; and use jackdaw_api::prelude::*;, and those names resolve at compile time through the SDK.

Scaffolded projects therefore have an empty [dependencies] table:

[package]
name = "my_tool"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]  # intentionally empty

BEI keybind caveat

Live-load activates windows, menu entries, operators, and panel sections immediately. BEI input contexts are the exception: add_input_context::<C>() needs &mut App, which only exists at startup. Keybinds declared via BEI don’t bind until the editor restarts. Extensions that don’t use BEI keybinds don’t need a restart.

Escape hatches

Install a prebuilt dylib

If you already have a compatible .so / .dylib / .dll (a teammate’s build, a CI artefact), use File > Extensions > Install prebuilt dylib and pick the file. The editor copies it into the extension directory and live-loads.

For in-house tools bundled into a custom editor binary, skip the dylib path entirely:

// your_editor/src/main.rs
fn main() {
    App::new()
        .add_plugins(
            jackdaw::EditorPlugins::default()
                .set(ExtensionPlugin::new().with_extension::<MyExtension>())
        )
        .run();
}

Nothing crosses a dylib boundary; everything is normal static linking. Use for tools you control and ship together with the editor.

Troubleshooting

  • bevy CLI not found: install it (cargo install --locked --git https://github.com/TheBevyFlock/bevy_cli bevy_cli) and make sure bevy is on your PATH.
  • SDK dylib not found: rebuild the editor with cargo run --features dylib. Without that feature, jackdaw doesn’t produce libjackdaw_sdk.so.
  • cargo exited with non-zero status during Build: your extension has a compile error. The status line shows cargo’s stderr.
  • picked path has no Cargo.toml: point at the project root, not the src/ directory.

In-tree examples

The workspace has two example extensions you can read or build against:

  • examples/extension/dynamic_extension/: operators with keybinds, availability checks, a dock window, and menu entries. Good reference for what the API supports.
  • examples/extension/viewable_camera_extension/: heavier scene manipulation through ExtensionContext::world().

They build like any other workspace crate. They’re there to exercise the API surface; day-to-day authoring should use the + New Extension / + New Game workflow described above.

Open challenges

This is the honest list. Stuff that’s not done, or is partly done, or is genuinely hard. Nothing here is shipped. If you want to take a swing at any of it, please file an issue first so we can talk through the approach.

Windows dylib loading

The dylib path (where the editor dlopens a hot-reloadable game .so) doesn’t currently work on Windows. The PE binary format has a 65,535 export cap, and bevy + jackdaw types together blow past that.

It’s a binary-format property, not a linker setting. Switching to rust-lld instead of MSVC link.exe was tried and doesn’t help. eugineerd attempted splitting bevy into multiple per-subcrate dylibs and ran into the diamond dependency problem described here.

Where to dig in: making per-bevy-subcrate dylibs work needs upstream bevy crate-type changes; we can’t fix this entirely inside jackdaw. The static-template path works on Windows today and is the recommended setup. If you have a clean idea for the multi-dylib split (especially one that avoids the diamond), let us know.

Play-In-Editor (PIE) maturity

PIE is the “click play to run your game inside the editor” flow. The first three phases shipped:

  • BRP foundation (the Bevy Remote Protocol plumbing).
  • Entity browser for the live game state.
  • Read-only remote inspector.

What’s not done: bidirectional editing (changing a value in the inspector and having it ride back to the game), WebSocket streaming instead of HTTP polling, full component widget metadata, and a clean story for spawning the game in its own process versus loading it as a dylib.

The big open question is in-process vs. out-of-process. In-process is cheap (no IPC) but a panicking game can take down the editor. Out-of-process is safer but needs a real protocol for state sync. Discussion lives in the GitHub issues and on Discord.

Where to dig in: pick a small slice (like “round-trip a single Transform edit”) and prototype the wire format.

Migration to BSN

Cart’s BSN PR for Bevy 0.19 establishes scene-document-as- source-of-truth at the engine level. Jackdaw’s current JSN-first refactor was deliberately shaped to mirror BSN, so the swap should be mostly mechanical.

What’s not done: the actual swap. We need to wait for BSN to land, then port. The risk is timing; BSN might land between a jackdaw release cycle, and we want to stay shippable throughout.

Where to dig in: track the BSN PR upstream, run the JSN-to-BSN diff on a test scene as soon as the upstream format is stable, and prototype the loader changes against a local bevy fork.

Engine-feature gaps

Compared to other game engines, jackdaw is missing a bunch. None of these are blockers; they’re places where someone with taste in the area could lead. One line each:

  • Animation graph editor. Started in crates/jackdaw_animation, not finished.
  • Particle / VFX editor. Not started.
  • Material graph editor (shader-graph style). Not started.
  • Light baking and lightmap pipeline. Not started.
  • Navmesh debug overlay. We have a navmesh component but no visualisation.
  • Cinematics / cutscene editor. Not started.
  • Audio mixer. Not started.
  • Localization (i18n). Not started.
  • In-editor profiler / frame-time inspector. Not started.
  • Asset import beyond GLTF (FBX, USD, batch texture compression). Not started.
  • Level streaming for large open worlds. Not started.

If you care about any of these, opening a small “here’s what I’d do” issue is the best starting point. We don’t want to solo-design any of them.

Asset processing pipeline

Right now asset processing only happens at editor runtime. If you want to pre-process textures or bake meshes for a CI build, you have to start the editor headlessly, which is not great.

andriyDev raised this in editor-dev. The natural shapes are:

  • Split the user’s game into a library plus multiple binaries (run, process). Closer to Unreal’s UAT model. Invasive for the static template.
  • Add a cargo jackdaw subcommand with process, build, etc. Closer to Unity’s CLI. Less invasive but more code in jackdaw.

Where to dig in: pick one shape and prototype it against a small game. We’d like to see the workflow before locking in the design.

Single-entity editor-only ergonomics

Today EditorOnly skips the whole entity from save, so to have a PlayerSpawn marker that ships and a visual indicator that doesn’t, you author a parent (with PlayerSpawn) and a child (with EditorOnly + a mesh).

Jan asked whether the same entity could carry both. It can’t today, because the save filter is at entity granularity. A future EditorOnlyVisuals marker that strips visual components (Mesh3d, MeshMaterial3d, etc) at save time but keeps the entity and its non-visual components would enable single- entity authoring. The cost is a small allowlist of “visual” component types that grows as bevy adds new ones.

Where to dig in: design the allowlist, file an issue, then implement. The semantics decision is the harder part than the code.

Brush face children as a custom relationship

Each brush spawns N face child entities for rendering. They carry EditorHidden (so they’re not in the outliner) and NonSerializable (so they’re not in the save). But Children queries on the brush still enumerate them, which means user code that walks brush children sees jackdaw’s implementation detail.

A custom Bevy relationship (not ChildOf) for face entities would solve this cleanly. The face entities would be reachable through the relationship but invisible to standard Children queries. The cost is a small per-frame propagation system that reads the brush’s GlobalTransform and writes the face’s.

Where to dig in: the relationship API in Bevy 0.18, and whether we can do this without breaking BrushFaceEntity queries that already work.

Configuration

Configuration is split across three places: the workspace Cargo.toml (feature flags), the user config directory (global preferences and dylib install dirs), and project.jsn (per-project settings).

Cargo features

The top-level jackdaw crate exposes:

  • default = []. The base editor.
  • dylib. Experimental dynamic-linking flow for hot-loadable extensions. Pulls in jackdaw_sdk plus Bevy’s dynamic_linking. Off by default; see Extending the Editor for what it gates.
  • hot-reload. Adds bevy_simple_subsecond_system for iterating on the editor itself. Editor-developer convenience, not for shipping games.

Templates ship with their own feature set. The game-static template’s Cargo.toml declares an editor feature that guards the editor binary, so a release build can drop it out:

[features]
default = []
editor = ["dep:jackdaw"]

cargo run builds the standalone game; cargo editor (or cargo run --bin editor --features editor) builds the editor host.

User config directory

Resolved via dirs::config_dir() joined with jackdaw. On Linux that lands at ~/.config/jackdaw/. The directory holds:

  • recent.json: launcher’s recent-projects list. Filtered to existing folders at startup.
  • keybinds.json: user-overridden keybinds. Defaults live in code; the file only contains overrides.
  • extensions.json: catalog of installed extensions (enabled/disabled, install state).
  • extensions/: drop a built .so / .dylib / .dll here to load it on the next editor start (only when the launcher binary is running with DylibLoaderPlugin).
  • games/: same idea, for game extensions.

You can edit any of these by hand; the editor watches the directory and reloads when files change.

Project file

project.jsn (see JSN Format) holds project-scoped settings:

  • default_scene: scene to open on project load.
  • layout: persisted dock layout, parsed as jackdaw_panels::LayoutState. Editing this by hand is not recommended; let the editor write it.
  • name, description: free-form metadata, shown in the launcher.

EditorPlugins builder

Programmatic config goes through the EditorPlugins plugin group. The default form is enough for most embedders:

#![allow(unused)]
fn main() {
App::new()
    .add_plugins(EnhancedInputPlugin)
    .add_plugins(jackdaw::EditorPlugins::default())
    .run();
}

Notes:

  • EnhancedInputPlugin must be added before EditorPlugins. We do this rather than adding it ourselves so user game plugins can also add it without a duplicate-plugin panic.
  • DylibLoaderPlugin is intentionally not in the group. The launcher binary opts in by adding it directly. Per-project static editor binaries should not add it; the dylibs in ~/.config/jackdaw/ were built against a different bevy compilation and panic at the FFI boundary if loaded into a static editor.

The builder API for swapping out built-in extensions or adding statically linked ones is documented in Extending the Editor.

Toolchain

The repo CI pins to a specific nightly in .github/workflows/ci.yaml (currently nightly-2026-03-05, matched against bevy_cli’s rust-toolchain.toml). We don’t ship a rust-toolchain.toml yet, so your local toolchain is whatever rustup has selected. If you see compiler errors that match no obvious code change, check the CI pin first.