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

Jackdaw

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 (.bsn). Your project stays a normal Bevy crate: the editor builds and plays the same cargo binary you run from a terminal, so cargo build and cargo run keep compiling plain crates.io Bevy with nothing jackdaw-specific forced into the manifest.

We are pre-1.0. Things change. Some pieces are still in active flux, 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, carving them with boolean operations, 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.
  • Load the same scene in a standalone Bevy binary through jackdaw_runtime, with no editor in the dependency graph.
  • Play the game from inside the editor, out of process, with live frames streamed into a panel.
  • 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 have used a brush-based level editor before, the geometry model will feel familiar: convex volumes carved and combined in place rather than meshes imported from a modelling tool. If you have used a scene editor, .bsn files play the same role as its scene files, except they are plain text you can read and diff.

What this book covers

  • Getting Started: install, scaffold a project, save a scene, or bring an existing Bevy game in.
  • 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: 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

Bug reports are most useful with the scene file, the steps that reproduced the problem, and what you expected instead.

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.

Installation

Jackdaw supports a signed precompiled release, a source checkout, and Cargo installation. All three provide the GUI, jd, the rustc wrapper, project scaffolding, and import.

Prerequisites

Install rustup and Cargo. On Linux, install Bevy’s system dependencies:

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

Check an installation with:

jd doctor

Which one to use

All three give you the editor, jd, and project scaffolding. Games build as ordinary cargo binaries against their own Bevy dependencies; the editor asks that binary for its type schema and launches it for Play. They differ mainly in whether the extension SDK (used for in-process editor extensions) is already built.

Extension SDKFirst game build
Precompiled releasealready built, nothing to do~9 min
cargo install~30 min, once per Jackdaw version~9 min
Source checkoutbuild the editor, then its SDK~9 min

The extension SDK is a full compilation of Bevy and the Jackdaw API that native editor extensions link against. A release archive ships it prebuilt. The other two compile it on your machine, once per Jackdaw version. That is a real half hour, so take the release archive unless you have a reason not to.

Your game still compiles its own copy of Bevy the first time you build it, around nine minutes, and every project pays that separately. The editor learns your component types from that binary’s schema extract, not by linking the game into the editor process. After the first build, rebuilds are 1 to 4 seconds, which is the number you actually live with.

Whichever you use, jd doctor reports which SDK is in play:

[ ok ] SDK: release bundle (/opt/jackdaw/sdk/x86_64-unknown-linux-gnu/libjackdaw_sdk.so)

Precompiled release

Tagged releases provide checksummed, provenance-attested archives for x86-64 Linux, x86-64 Windows, and Apple Silicon macOS. Extract the archive and run jackdaw. Intel macOS users currently build from source.

The archive includes its pinned SDK, so nothing of Jackdaw is compiled on your machine. Extract it and you can create a project immediately. That project’s first build still takes around nine minutes, since it compiles its own Bevy; see Which one to use.

Cargo install

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

The editor is installed from git rather than crates.io because it depends on bevy_rerecast by git, which crates.io does not accept. That restriction is the editor’s alone: the crates your own project depends on (jackdaw_runtime, jackdaw_extension, and everything under them) are published normally, so a scaffolded project resolves from the registry like any other Bevy project.

The install provides jackdaw, jd, and jackdaw-rustc-wrapper; do not install workspace packages individually.

This path has no prebuilt extension SDK, so it prepares one on first use: roughly half an hour of compiling Bevy, once per Jackdaw version, before native extensions can load. The editor shows a progress screen while it runs; jd setup does the same thing from a terminal if you would rather get it out of the way first. Cargo installs are self-contained; use a precompiled release to load signed native extensions.

Jackdaw versions track Bevy minors: Jackdaw 0.19 targets Bevy 0.19, and so do the jackdaw_* crates your project depends on.

Source checkout

git clone https://github.com/jbuehler23/jackdaw
cd jackdaw
cargo run --bin jackdaw

The checkout uses the SDK under its own target/, in preference to any prepared one, because editor extensions must link the SDK co-built with the editor running them. That also means cargo clean throws the SDK away. jd doctor reports which SDK is in play, so it is clear when a checkout’s is the one being used.

To build an editor with live native extension loading, use the same shared-SDK mode as releases:

cargo run --bin jackdaw --features dylib --target "$(rustc -vV | sed -n 's/host: //p')"

Create or import a project

Use the launcher’s New Game, New Extension, and Import Bevy Project actions, or:

jd new my-game                          # also: --extension, --path <dir>, --no-git
jd open my-game

jd import /path/to/existing-game        # preview
jd import /path/to/existing-game --apply

jd import previews exact file operations and changes nothing without --apply. Jackdaw keeps editor state and the extracted type schema in the project’s gitignored .jackdaw/ directory. Ordinary cargo run remains a normal game build and does not invoke Jackdaw.

jd new initialises a git repository, the way cargo new does, unless the destination already sits inside one or you pass --no-git.

If anything looks wrong, jd doctor reports the build prerequisites, and jd doctor --project <path> adds the project’s own setup state, including whether its dependencies resolve.

After a Jackdaw update, jd upgrade <path> moves a project onto the new version.

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

Two starting paths:

  • New Project > Game on the launcher (or jd new my-game from the terminal). You get a normal Bevy crate: a lib.rs with a GamePlugin, a main.rs that runs the standalone game, a starter scene, and a jackdaw.toml. 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 scene next to ones you have.

A new project opens immediately. The editor builds the project’s cargo binary in the background (same as cargo build in the project root) and asks it for its reflected type schema. Your own components show up in the inspector once that finishes. Placing brushes and saving scenes works right away, so you do not have to wait for it.

Expect that first build to take around nine minutes: it compiles Bevy from source, the same as any Bevy project. Every project pays it once. Rebuilds after that are 1 to 4 seconds, so this is the only time you will sit through it.

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). A project from the Game template already has assets/scene.bsn open, so this writes straight back to it. A scene created with File > New Scene asks where to put the file the first time; pick assets/scene.bsn to match what the template loads.

Open that .bsn in your text editor if you want to peek. It is plain text, with one entry per entity and reflected component data inline. See BSN Format for the syntax.

See it run outside the editor

From the project folder:

cargo run

This launches the standalone binary. main.rs adds jackdaw_runtime::JackdawPlugin, which registers the asset loader for .bsn files, and the template’s GamePlugin spawns a JackdawSceneRoot pointing at scene.bsn. 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.

Bevy cannot load .bsn on its own; the loader ships in jackdaw_runtime, which is an ordinary dependency of your crate.

What you have now

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

Importing an existing project

Open a Bevy 0.19 project through the launcher’s Import Bevy Project action, or preview the integration from a terminal:

jd import /path/to/game

Import planning is side-effect free. The launcher shows an Apply changes confirmation; the CLI requires:

jd import /path/to/game --apply

The plan verifies the Bevy minor, creates jackdaw.toml, creates the gitignored .jackdaw/ build directory, and ensures the project exposes a library plugin. A common bin-only App::new() program is converted into GamePlugin as part of the same preview, with the original proposed as src/main.rs.bak. Unsupported source shapes receive a library stub and a clear manual-move note.

Jackdaw never edits the project’s Cargo manifest, lockfile, toolchain, or ordinary target/. cargo run therefore behaves exactly as it did before. For the same reason, migrated code never references a crate the project does not already depend on: add jackdaw_runtime yourself to load authored .bsn scenes in the game.

Cargo workspaces

Point the import at the workspace root. Jackdaw resolves the member that depends on Bevy, writes jackdaw.toml at the root, and records which member it chose:

package = "my-game"

When several members depend on Bevy, import says so and asks which one:

jd import /path/to/workspace --package my-game --apply

Version pins

Setup records the versions the project was integrated against:

[jackdaw]
version = "0.19.0"
bevy = "0.19"

Jackdaw compares these on open. A different Bevy minor is reported before any build starts, because the editor and your game code must share one Bevy version; pass --allow-bevy-mismatch (or Set up anyway in the launcher) to integrate regardless and deal with it later.

Upgrading a project

When Jackdaw updates within the same Bevy minor, the project still builds, but it records the old version and still requests the old release line of the jackdaw_* crates. The launcher offers to update it on open, or:

jd upgrade /path/to/game          # preview
jd upgrade /path/to/game --apply

That rewrites the [jackdaw] pins and moves any jackdaw_* dependency to the matching version, leaving your run configurations, comments, features, and every other dependency untouched. Path and git dependencies are left alone.

Checking a project

jd doctor --project /path/to/game

reports the build prerequisites, the resolved package, whether a library target and plugin were found, the version pins, and whether the project’s type schema has been built yet.

Expected game shape

Game systems and resources live in a plugin exported by src/lib.rs:

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

#[derive(Default)]
pub struct GamePlugin;

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

Keep ambient plugins such as DefaultPlugins and PhysicsPlugins in the standalone main.rs. To expose authorable components, derive Bevy reflection:

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

Use Rebuild Project or jd build. Manual build is the default; Toggle Auto Build opts in and persists that choice for this project. Play launches the project’s own cargo binary in a separate process.

Authored .bsn scenes are loaded in the game through jackdaw_runtime.

Viewport navigation

The viewport uses the fly-camera scheme common to level editors: 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: a convex polyhedron defined by its faces, with per-face materials and UVs, edited in place without a separate modelling tool. Brushes serialize directly into the scene .bsn, 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 .bsn to open it.
  • Drop new files into assets/ from your file manager. The editor watches assets/, 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 asset 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 .bsn. References use #Name.
  • Project-wide: it lives in assets/catalog.bsn 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 the scene file plus assets/catalog.bsn. 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.

Paint channels

Choose the paintbrush in the terrain toolbar to edit integer channels such as biome, ground type, or buildability. Add a channel and one or more palette values in the Paint Channels section, select the value to write, and drag over the terrain. Hold Ctrl while painting to restore value 0.

Show Painted Values tints the terrain with the active palette so the stored data is visible even before a game material consumes it. New channel and palette entries receive generated names and colours; project-specific descriptor names, integer widths, labels, values, and colours can also be authored directly in the scene document.

Quantization

Enable quantization when the game needs a fixed metric grid or terraced elevations. Cell Size controls the world-space distance between samples and Height Step controls the elevation interval. Sculpt, generation, and erosion snap new changes while quantization is enabled. Click Apply once to snap heights that existed before it was enabled.

Turning quantization off stops future snapping but does not alter heights that are already stored.

Scatter

The Scatter section places model instances across the selected terrain. Add one or more model assets, then configure density, spacing, scale, yaw, normal alignment, and an optional paint-channel mask. A seed produces the same placement for the same terrain and channel data.

Re-running the same scatter group replaces untouched generated instances. Instances moved, rotated, or scaled by hand are preserved. The whole run is a single undoable edit.

Sidecars and export

Scene files keep the small terrain descriptor, while heights and per-cell channel values are stored beside the scene in versioned .jdterrain files. Save and move those sidecars with the .bsn scene that references them.

For a headless runtime or another engine, export the authored terrain with:

jd export-terrain path/to/scene.bsn --out path/to/export

The export contains height and channel images, a manifest, and placed-scene data. Quantized projects normally keep cell size and elevation step on the terrain; unquantized scenes can pass --cell-size and --elevation-step together for an export-only grid. Add --raw-heights when the consumer also needs the raw height buffer.

Export format contract

This is a cross-repo contract: an importer in another repo is built against it, so treat the shapes below as stable.

  • manifest.json, format_version: 2. Bumped whenever a field is added, removed, or reinterpreted; an importer should check it and refuse (or degrade explicitly) on a version it does not understand, rather than assume the shape it expects.
  • heightmap.png is a 16-bit grayscale PNG. Every pixel decodes to a world-space height via height = manifest.heightmap.base_m + pixel * manifest.heightmap.step_m (encoding: "unsigned-steps-from-base"). Quantized exports set step_m to the terrain’s elevation step; unquantized exports derive step_m from the actual authored height span (not from max_height_m, which is a configured ceiling and can differ from the real data range).
  • Each paint channel is its own PNG (channels/<name>.png, 8- or 16-bit depending on the channel’s element width) plus a manifest entry: name, file, bit_depth, element ("u8" / "u16"), and palette – a list of { value, label, color } entries, color as #rrggbb. Channel names are guaranteed unique in one export: the writer refuses the whole export if the scene’s channel names collide, either exactly or after filename sanitization.
  • placements.json, its own format_version: 1, lists every scattered / placed instance: name, asset (nullable), translation_m / rotation_quat / scale, and components (a free-form JSON map of any extra authored component data on that instance).
  • heights.f32, present only with --raw-heights: the raw height buffer as little-endian f32, row-major, unquantized and unscaled – for a consumer that wants the source values rather than the quantized PNG encoding.

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. Two separate causes land on the same symptom. First, 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. Second, even with the crate present, a terrain’s heights and paint channels live in a .jdterrain sidecar next to the .bsn scene, not in the scene file itself (see “Sidecars and export” above) – if you load a .bsn scene directly at runtime rather than through the jd export-terrain pipeline, every referenced .jdterrain sidecar has to ship and load alongside it, or the terrain reads as flat.

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 and let them settle.

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. The wireframe tracks scale gizmo edits. 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 .bsn file. A “project” is a normal Bevy crate: a folder with a Cargo.toml, a jackdaw.toml, an assets/ directory, and a .jackdaw/project.json editor-settings file (legacy .jsn/project.jsn migrates on open). 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 in a new tab. The picker starts in the current project’s assets/ folder.
  • Ctrl+T creates a new empty scene tab; it is unsaved until you Ctrl+S it.

Scene files are human-readable, line-diffable, and designed to read in git diff without making you cry. Legacy .jsn scenes still open (import-only); see BSN Format.

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: pick Game or Extension, instantiated from a template embedded in the editor.
  • An Import action for opening an existing Bevy project; see Migrating an Existing Project.

Recent projects with missing folders are filtered out. Click a project to open it; the editor transitions into AppState::Editor and restores the scenes that were open last time.

What opens when you open a project

The editor restores the tabs you had open last time. Those live in .jackdaw/project.json as last_open_tabs, with last_active_tab picking which one is focused; entries whose files have gone missing are skipped.

If that leaves no tabs (a fresh project, or every remembered path is gone), the editor falls back to assets/scene.bsn, then to a legacy assets/scene.jsn if that is all there is, and finally to a new untitled scene. So you never land in the editor with nothing open.

Multi-scene projects

Nothing stops you from putting many .bsn 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/), and Bevy’s runtime asset path points at assets/. If you put a scene file 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.bsn. If you want a different path, use File > Save As.

Play-in-editor

Play-in-editor (PIE) runs your game as a real process and streams its frames into the editor, so you can play, inspect, and live-edit a running build without leaving jackdaw. The game runs in its own process, so a crash takes down the game, not the editor.

PIE keeps two surfaces strictly separate:

  • The Game panel is a pure monitor of the running game’s frame.
  • The Viewport is always an editing surface for the authored scene. It never composites the game frame over your scene.

What you need

Nothing compiles at play time once the project is built. The editor builds your game as an ordinary cargo binary when the project opens; Play launches that same executable and connects to it over IPC.

The Play dropdown is filled from the run configurations in your project’s jackdaw.toml ([[run]] entries carrying a name, environment variables, arguments, an instance count, and a working directory). A project with no jackdaw.toml still plays: the editor synthesizes a single default run. See Configuration for the fields.

How many configs you define is up to your game. Some games run a single process; others split into several that you launch together. Configs differ only in launch environment, never in what gets built. PIE treats each launched process as an instance and streams the focused one.

Open the Game panel before you start. It docks in the bottom dock area next to Assets.

Launching

The play controls header carries Play, Pause, Stop, and Reload, plus a window-mode button that reads Embedded or Windowed. The window-mode button sets the mode for the next launch.

  • Embedded (default): no separate game window opens. The game renders off-screen and streams into the Game panel at full frame rate. This is the mode you want for input capture and picking.
  • Windowed: the game opens its own OS window. The Game panel still mirrors the active in-game camera once one exists, but menus do not stream and input capture is not offered.

Hit Play to launch. The Game panel starts streaming immediately, beginning with whatever the game shows first (often a menu or title screen). The outliner shows a LIVE badge with the running instance’s name.

When more than one instance is running, the instance picker in the outliner header switches which one the Game panel and Live tree follow.

Playing the game

The Game panel header has a Play | Select mode bar.

In Play mode, click inside the panel to engage input capture (or use the Play Input header button). While captured:

  • Keyboard and mouse forward to the game. WASD, mouse-look, scroll, clicks, and typing all reach it.
  • Editor keybinds are suppressed. Tool keys and Ctrl+S go to the game, not the editor.
  • A Playing, Shift+Esc to release chip shows, and the panel border takes the capture accent.

Plain Esc forwards to the game (so the in-game menu still opens). Press Shift+Esc to release capture and return control to the editor. Capture also releases on its own if you stop the game, switch instances, click away to another application, or close the panel, and any keys you were holding are released so nothing stays stuck down.

Selecting entities from the frame

Switch the mode bar to Select. Game input stops, and the cursor becomes a picker over the streamed frame.

  • Click an object in the frame to select it. The selection appears in the outliner’s Live tab and the inspector shows its live values.
  • Picking reads the real frame through the game’s own camera, so it needs no alignment and reaches runtime-only entities (the player character, spawned props) that have no authored counterpart.
  • The game draws a bounding box around the picked entity, and the Live tree expands to reveal the selected row.
  • Selecting a row in the Live tree moves the box to that entity.

Menu and UI elements are not pickable; they are not streamable scene entities.

Scene and Live trees

The outliner header has a Scene | Live tab switch:

  • Scene shows the authored tree of the open scene file. This is the same hierarchy you edit when the game is not running.
  • Live shows the entities the focused game instance currently has, including runtime-only ones. Authored entities the game has not spawned do not appear here.

The two trees are independent. When the game shows a menu, the Live tab shows the menu’s entities while the Scene tab still shows your authored scene, and the Viewport keeps showing that scene with gizmos, fully editable. You can select and edit authored entities in the Viewport or Scene tab at any time without disturbing the running game’s frame.

Stopping and reloading

  • Stop ends the game process. The Game panel returns to its idle state and the LIVE badge clears.
  • Reload relaunches with the current window-mode setting, which is how you apply a change to the Embedded / Windowed button.

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
MToggle snapping (same as the toolbar magnet)
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
Numpad 1Front view (orthographic, looking down +Y)
Numpad 3Right view (orthographic, looking down +X)
Ctrl+Numpad 3Left view
Numpad 7Top view (orthographic, looking down +Z)
Numpad 5Toggle perspective / orthographic
HomeFrame all entities
Ctrl+Shift+WToggle wireframe
[Decrease grid size
]Increase grid size
Ctrl+Alt+ScrollChange grid size

File

KeyAction
Ctrl+SSave scene
Ctrl+OOpen scene in a new tab
Ctrl+TNew scene tab
Ctrl+ZUndo
Ctrl+Shift+ZRedo

Play-in-editor

KeyAction
EscForward to the game (opens its in-game menu) while capturing
Shift+EscRelease input capture, return control to the editor

See Play-in-editor for the full workflow.

Architecture

Jackdaw is a standalone editor built from Bevy 0.19 plugin sets. The editor and the standalone runtime share the same scene format 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 composable editor is delivered by jackdaw_editor as JackdawEditorPlugins, a Bevy PluginGroup. The editor binary looks like:

#![allow(unused)]
fn main() {
App::new()
    .add_plugins(DefaultPlugins.set(editor_window_plugin()))
    .add_plugins((PhysicsPlugins::default(), EnhancedInputPlugin))
    .add_plugins(JackdawEditorPlugins::default())
    .run()
}

JackdawEditorPlugins pulls in everything jackdaw needs: the launcher, viewport, hierarchy, inspector, brush tools, asset browser, scene IO, and the extension loader. Game project code is not compiled into this binary; the editor builds the project’s own cargo binary and talks to it out of process (see below).

The game’s main adds JackdawPlugin from jackdaw_runtime, which knows how to load authored scenes and answer schema queries, but includes none of the editor UI. Gameplay usually lives in a Bevy plugin (often named GamePlugin) that main adds alongside it.

WindowPlugin is set by editor_window_plugin().

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.

Project code in the editor

A jackdaw game is a normal Bevy binary. When you open one, the editor runs cargo build in the project root (sharing the user’s Cargo.toml, lockfile, target dir, and toolchain) and asks the freshly built executable for its reflected type schema via --jackdaw-extract-schema. The editor represents those types as data rather than mapping game code into its process.

Play is the same artifact: the editor launches the project’s own binary as a child process and talks to it over IPC. What you Play is what cargo run would run, and a game crash cannot take down the editor.

Editor extensions build as dylibs against the SDK so they can share the editor’s Bevy types and load in-process.

Scene format

Scenes are stored as .bsn files under assets/. Each entity lists its reflected components inline. The live in-editor document is the BSN AST (SceneBsnAst); saving writes it back out as .bsn text, and that is the only format anything writes. The serializer skips types tagged with @EditorHidden, the entity-level EditorHidden marker, NonSerializable, and EditorOnly. Legacy .jsn scenes can still be imported; see BSN Format.

Outside the editor, jackdaw_runtime registers a Bevy AssetLoader for the bsn extension, since Bevy has no built-in loader for the format. The loader processes scene 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.

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 normal Bevy library that depends on jackdaw_api and implements the JackdawExtension trait. Opening the extension project in jackdaw builds and loads it; the Extensions dialog installs prebuilt extension dylibs. Extensions can register operators, windows, menu entries, and keybinds. See Extending the Editor for the full story.

The dylib 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 loaded 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 (animation graph, asset processing pipeline, and the rest) 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

One direct dependency, no editor in the dependency graph:

  • jackdaw_runtime: the standalone scene loader for authored .bsn scenes, the optional physics feature that builds avian colliders from authored data, and the EditorMeta / ReflectEditorMeta reflect attributes (EditorCategory, EditorDescription, EditorHidden) that user game crates use on their components.

JackdawPlugin registers a Bevy AssetLoader for the bsn extension. Bevy ships no loader for that format, so a game without jackdaw_runtime cannot open an authored scene.

It pulls in the scene and geometry crates:

  • jackdaw_bsn: the .bsn scene format, its parser, and the scene document.
  • jackdaw_scene_types: the shared components (Brush, scene node ids, custom properties).
  • jackdaw_geometry: brush data structures (BrushFaceData, CSG, triangulation). Needed at runtime because the standalone game has to rebuild brush meshes from the serialized planes.

jackdaw_jsn is not in this graph. It is a read-only importer for the legacy .jsn format and only the editor depends on it.

The game template’s Cargo.toml shows the canonical shape: a normal Bevy crate with bevy, jackdaw_runtime, and a physics crate, and nothing editor-related.

What the editor adds on top

The jackdaw package is the official editor installation. The public jackdaw_editor crate exposes the JackdawEditorPlugins composition seam. They depend on nearly everything else 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.
  • jackdaw_camera_rig: authorable first/third-person camera rig components plus the runtime driver that moves them. Optional, behind the default-on camera_rig feature.
  • jackdaw_csg: the glue between brushes and the manifold3d mesh-boolean kernel.
  • jackdaw_snap, jackdaw_select, jackdaw_uv, jackdaw_pick, jackdaw_hull, jackdaw_material: engine-agnostic editing math (snapping, half-edge selection traversal, UV projection, ray and point queries, convex hulls, PBR texture-set detection). No bevy dependency; the editor is a thin adapter over them.
  • jackdaw_multiplayer, jackdaw_multiplayer_editor, jackdaw_multiplayer_lightyear: networking authoring. The editor writes replication metadata only; the lightyear backend lives game-side.
  • jackdaw_localization: editor string catalogue.
  • bevy_window_chrome: custom title bar window chrome for Bevy.

Play and the command line

  • jackdaw_project_build: the build pipeline. Binary builds for games, SDK/shim dylib builds for extensions, schema persistence, SDK path resolution, and first-run SDK bootstrap. Deliberately bevy-light so the CLI can link it without dragging in a renderer.
  • jackdaw_schema: the project type-schema wire format shared by games (which produce it) and the editor (which consumes it).
  • jackdaw_cli_internal: bevy-light command implementations used by jd. Release-only packaging is invoked through cargo xtask.
  • jackdaw_pie_protocol: the IPC message types and the jackdaw.toml run-configuration manifest shared by the editor and the game binary.

Extension dylib plumbing

Crates for building and loading extension dylibs against the SDK:

  • jackdaw_api: the public surface extensions link against. Re-exports bevy plus the operator / extension traits (including JackdawExtension). Its dynamic_linking feature selects the bevy feature set the editor and the SDK share, so the two resolve to one bevy. Despite the name it no longer switches bevy to its dylib build; the name stays because extension authors already write it.
  • 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 facade dylib extension builds link against via --extern bevy=libjackdaw_sdk.so. Bevy and Jackdaw runtime types live in separate bevy_dylib and jackdaw_dylib shared libraries so the editor and every loaded extension share one TypeId universe. Games do not use this path.
  • jackdaw_dylib: the dynamic-loader shim that dlopens dylibs at runtime.
  • jackdaw_loader: the host-side resource that tracks loaded dylibs, plus the crash quarantine.
  • jackdaw_rustc_wrapper: the rustc interceptor crate. Ships its jackdaw-rustc-wrapper binary, which the editor’s build pipeline invokes to inject the right --extern flags. User projects never configure it; the editor drives it from the generated .jackdaw/ build root.

Other crates

  • jackdaw_fuzzy: fuzzy-match scoring for the picker / command palette. Tiny.
  • jackdaw_jsn: read-only importer for the legacy .jsn format. Nothing writes .jsn; opening one converts it to .bsn.

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. The editor builds your project’s game binary in the background when the project opens and extracts its type schema; once that finishes, open the inspector on an entity, click + Add Component, type PlayerSpawn. It shows up.

If you add a component while the editor is already running, run Rebuild Project (or jd build in a terminal) to pick it up. Rebuilds are on request rather than automatic; Toggle Auto Build switches to rebuild-on-source-change.

A few things make this work without ceremony:

  • Bevy’s reflect_auto_register registers the type when the schema extractor runs your game binary, so you don’t need app.register_type::<PlayerSpawn>() and there is no jackdaw-specific registration code anywhere. A type in a dependency crate that your library never references can be stripped by the linker before registration runs; register it explicitly if it never shows up.
  • jackdaw_runtime enables bevy’s reflect_documentation feature, 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 a scene file. 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 GamePlugin {
    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 the scene file. 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 project hasn’t been rebuilt since you added the type. Run Rebuild Project or jd build.

Doc comment doesn’t show as tooltip. Tooltips need bevy’s reflect_documentation feature. jackdaw_runtime turns it on; if you patch or vendor your own bevy, make sure reflect_documentation is in its 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.

BSN format

BSN (“Bevy Scene Notation”) is the on-disk format for jackdaw scenes. It is a reflection-based notation: each entity lists its components by full type path, with values in a compact struct / enum / tuple syntax that round-trips through Bevy’s reflect system. Scene files are human-readable and line-diffable in git.

The parser and scene document live in crates/jackdaw_bsn. The live in-editor document is the BSN AST (SceneBsnAst); saving writes it back out as .bsn text. Source of truth for the grammar is that crate; this page is the orientation.

Legacy JSN import

.jsn (“Jackdaw Scene Notation”) is the previous scene format: JSON with a fixed schema, implemented in crates/jackdaw_jsn. It survives as an import-only path. Opening a legacy .jsn scene converts it to .bsn on disk (the original is kept as a .jsn.bak backup), and the editor works with the .bsn from then on. Nothing writes .jsn any more; jackdaw_jsn is a read-only importer.

Scene shape

A scene is a list of root entity nodes. Each node names its components; child entities nest under bevy_ecs::hierarchy::Children.

#Root
bevy_transform::components::transform::Transform
bevy_camera::visibility::Visibility::Visible
bevy_ecs::hierarchy::Children [
    #Main Camera
    bevy_camera::components::Camera3d
    bevy_transform::components::transform::Transform {
        translation: glam::Vec3 { x: 0.0, y: 6.0, z: 12.0 },
        rotation: glam::Quat { x: -0.216, y: 0.0, z: 0.0, w: 0.976 },
    }

    #Sun
    bevy_light::directional_light::DirectionalLight
]
  • #Name labels the entity (its Name component).
  • A bare type path is a component at its default value.
  • Type { field: value, .. } sets struct fields; omitted fields keep their defaults.
  • Type::Variant is an enum value; Type(value) a tuple struct.
  • bevy_ecs::hierarchy::Children [ .. ] nests child nodes.

Component keys are full type paths (the same string the inspector shows under “type path”). Values are whatever Bevy’s reflect produces for that type, so nested types spell out their own paths (glam::Vec3 { .. }). Children come after their parent, so parent / child order is a property of the nesting, not of a flat entity list.

Asset references

Materials and other shared assets are referenced by name:

  • #Name for a scene-local asset, defined inline in the same .bsn file.
  • @Name for a project-wide asset, resolved from the project catalog.

Both prefixes resolve against the same name-to-handle table at load time, populated from the scene’s own inline definitions and the project catalog. A name that resolves to nothing falls back to a default handle rather than failing the load, so a missing material shows up as untextured geometry, not an error.

A component value that is a plain path string (no prefix) is loaded through the asset server as a file path instead.

Project file

Per-project editor settings live in .jackdaw/project.json, a plain JSON file inside the editor’s build directory:

{
  "name": "My Game",
  "description": "",
  "default_scene": "assets/scene.bsn",
  "last_open_tabs": ["assets/scene.bsn"],
  "layout": { }
}

All scene paths here are relative to the project root, so they keep working when the folder moves. last_open_tabs is what the editor actually reopens; default_scene is reserved and not yet consulted. layout is the persisted dock layout and is intentionally opaque to the config (consumers parse it as the jackdaw_panels workspace state). Legacy projects that keep a .jsn/project.jsn or root project.jsn are migrated to .jackdaw/project.json on open.

Catalog file

Project-wide named assets live in assets/catalog.bsn. Any scene in the project can reference them with @Name. Legacy catalogs at .jsn/catalog.jsn or assets/catalog.jsn are read for migration and rewritten to assets/catalog.bsn on the next save.

What is not in BSN

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

Extending the editor

Jackdaw has two deliberate extension seams.

Custom standalone editors

jackdaw_editor exposes the same Bevy plugin group used by the official GUI:

use bevy::prelude::*;
use jackdaw_editor::prelude::*;

fn main() -> AppExit {
    App::new()
        .add_plugins(DefaultPlugins.set(editor_window_plugin()))
        .add_plugins((EnhancedInputPlugin, PhysicsPlugins::default()))
        .add_plugins(JackdawEditorPlugins::default())
        .run()
}

Use normal PluginGroup controls to disable or replace editor plugins and add your own. This is unrestricted compile-time Rust composition and remains the right choice for deeply customized editor distributions.

Runtime extensions

Marketplace extensions use the focused jackdaw_extension crate:

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

#[derive(Default)]
pub struct MyTool;

impl JackdawExtension for MyTool {
    fn id(&self) -> String { "example.my-tool".into() }
    fn label(&self) -> String { "My Tool".into() }

    fn register(&self, registrar: &mut ExtensionRegistrar<'_>) {
        // register operators, panels, menus, keymaps, and host-owned state
    }
}
}

Everything installed through ExtensionRegistrar is owned by that extension. Disable, update, and uninstall remove those registrations immediately. Superseded native libraries remain safely mapped but unreachable until the process exits.

Runtime extensions deliberately cannot install extension-owned Bevy component metadata or reflected Rust types. Use a custom editor build when that level of access is required.

Signed bundles

Create one publisher key, once:

jd extension keygen

That writes publisher-key.pk8 into the Jackdaw data directory and refuses to overwrite an existing one, because publishing an update under a new key makes every user repeat the trust decision. Pass a path to keep it elsewhere.

Then, in the extension project, build and pack:

jd build
jd extension pack
jd extension verify my-tool-0.1.0-x86_64-unknown-linux-gnu.jdext

pack reads the bundle’s identity, version, publisher, license, and homepage from the project’s Cargo.toml:

[package]
name = "my-tool"
version = "0.1.0"
authors = ["Example Studio <hello@example.com>"]
license = "MIT OR Apache-2.0"
repository = "https://example.com/my-tool"

[package.metadata.jackdaw]
label = "My Tool"

Any of those can be overridden with --id, --label, --version, --publisher, --license, --homepage, --key, --out, and --library.

Build and pack separately on Linux, Windows, and macOS. A bundle records the target triple and the SDK ABI string it was built against, and installs only into a Jackdaw that matches both, so publish one bundle per target per Jackdaw release.

Users install signed .jdext bundles from Extensions or with:

jd extension install my-tool.jdext
jd extension list
jd extension disable example.my-tool
jd extension enable example.my-tool
jd extension uninstall example.my-tool

The manifest and signature are checked before native code is loaded. Bundles must match the exact Jackdaw SDK ABI and target. Trusting a publisher is an explicit confirmation because native extensions run with the user’s full permissions. Updates are staged by version and activated atomically; a failed activation leaves the previous version available for recovery. Inter-extension dependencies are not supported by this bundle format.

Distribution

Jackdaw does not host a registry. It provides the pieces an external one needs: a signed bundle format, a compatibility key, and installation straight from a URL.

Publish .jdext files wherever you like, and users install them with:

jd extension install https://example.com/my-tool-0.1.0-x86_64-unknown-linux-gnu.jdext

A URL and a local path go through the same gate. The signature, the library checksum, the ABI and target match, and the publisher trust prompt all run on the fetched bytes exactly as they do on a file, so a remote install is never less checked than a local one. Plain http:// is refused.

Serving the right artifact

A bundle only installs into a Jackdaw whose compatibility key matches, so a marketplace has to know the client’s before it offers a download. Ask it:

$ jd extension abi --json
{"sdk_abi":"jackdaw-0.19.0-bevy-0.19-rustc 1.90.0","target":"x86_64-unknown-linux-gnu",
 "jackdaw":"0.19.0","bevy":"0.19"}

sdk_abi covers the Jackdaw version, the Bevy minor, and the exact rustc that built the SDK; target is the platform triple. Key your catalogue on both. In practice that means one bundle per target per Jackdaw release, rebuilt and re-signed when Jackdaw updates. jd extension verify reports what a given bundle claims, without installing it.

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 hardening

The editor loads extension code as dylibs built against its SDK proxy. On Windows, a PE export table addresses its entries with a 16-bit ordinal, so 65,535 is the ceiling and no linker escapes it. Builds split the runtime into bevy_dylib and jackdaw_dylib beside the SDK facade, so each library has its own table. Measured on the current split, the hottest table is bevy_dylib (~46k exports in the workspace debug profile, ~41k in release); the Jackdaw runtime and the facade are a few thousand and a handful respectively. The release job measures every table and fails above 60,000. Two gotchas the split codified: LTO stays off for Windows binaries, because a PE file cannot import data across a DLL boundary once inlining creates direct references to another library’s statics, and the SDK build disables incremental codegen, which otherwise leaves undefined hidden symbols across the dylib boundary.

What remains is headroom and those link-model gotchas: Bevy’s export surface grows with the engine, and a regression that merges the runtimes again (or drops the split on one profile) would put Windows back against the ceiling.

Where to dig in: keep the release export check green, and watch bevy_dylib’s count when bumping Bevy or widening what extensions share.

Play-In-Editor (PIE) depth

PIE is the “click play to run your game” flow. The process model is settled: the game always runs out of process as its own cargo binary over IPC, with zero play-time compilation once built. Frame streaming into the Game panel, input capture, click-to-select picking, and the Live entity tree all shipped; see Play-in-editor.

What’s not done: deeper live editing (a broader set of component edits riding back into the running game and into the authored scene), richer widget metadata for live values, and protocol maturity across more component types.

Where to dig in: pick one component family that doesn’t round-trip yet and follow it through the IPC lanes.

Upstream BSN alignment

Jackdaw’s scene document is BSN: .bsn files are the authored format, the live in-editor document is the BSN AST, and .jsn survives only as a read-only importer. What remains is staying aligned with the upstream Bevy scene work as its APIs settle, and upstreaming the pieces of jackdaw’s writer that make sense there.

Where to dig in: track the upstream scene-notation APIs and diff them against crates/jackdaw_bsn as they move.

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.

Half of the second shape below now exists: jd drives the editor’s build machinery from a terminal with build and run. What is missing is a process step and the asset-processing pipeline behind it. The remaining shapes:

  • Split the user’s game into a library plus multiple binaries (run, process), with processing driven from the project’s own binaries. Invasive for the project template.
  • Extend jd with a process subcommand alongside build. 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).

A single entity cannot carry both 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.19, and whether we can do this without breaking BrushFaceEntity queries that already work.

Configuration

Configuration is split across three places: jackdaw.toml in the project root (package selection and run configurations), the user config directory (global preferences and extension install dirs), and .jackdaw/project.json (per-project editor settings). A fourth location, the SDK, is resolved rather than configured; see Where the SDK lives.

jackdaw.toml

The one jackdaw-specific file in a project. Everything in it has a working default; a project with an empty (or missing) file still opens and plays.

# In a cargo workspace, the member jackdaw builds as the game.
# package = "my-game"

[[run]]
name = "Play"
# instances = 2
# env = { SERVER_ADDR = "127.0.0.1:5000" }
# args = []
# cwd = "some/subdir"

Top-level keys:

  • package: which workspace member is the game. Single-package projects omit this.
  • plugin: optional name of the project’s root Bevy Plugin type. Recorded by import/setup and checked by jd doctor; Play launches your cargo binary, which must add the plugin itself in main.rs.

Each [[run]] entry is one item in the Play dropdown. Every run launches the same already-built game binary; entries differ only in launch environment, never in what gets built. Fields:

  • name: dropdown label. Defaults to Play.
  • instances: number of individually launchable copies of this config (Label #1..#N). Defaults to 1.
  • env: environment variables set on the game process. This is the game’s input surface for config (server address, role, and so on).
  • args: extra command-line arguments appended for the game.
  • cwd: working directory; defaults to the project root.
  • mode: engine-execution axis; the default is normal play, and editor-preview is reserved.

There is no bin or feature selection; runs don’t build anything. If the file is missing, the editor synthesizes a single default run.

The .jackdaw/ directory

.jackdaw/ is the editor’s per-project scratch space: persisted editor settings (project.json), the extracted type schema from the game binary, and for extension projects the generated shim crate and SDK-linked build target. It is gitignored (the scaffold and import both add the entry), owned entirely by the editor, and safe to delete; the next project open rebuilds what it needs. The editor never touches the project’s own Cargo.toml, Cargo.lock, target/, or toolchain.

Command line

jackdaw is exclusively the GUI. jd is the sole public command:

  • jd new <name> [--extension]
  • jd import [path] [--plugin <Type>] [--apply]
  • jd open [path]
  • jd build [--project <path>]
  • jd run [--project <path>]
  • jd setup
  • jd doctor
  • jd extension <keygen|pack|install|verify|list|enable|disable|uninstall>

Import previews by default and performs no writes without --apply. Release-only package-sdk and bundle operations live under cargo xtask.

Where the SDK lives

The SDK is the proxy dylib plus the compiled closure that extension builds link against. The editor resolves it in this order, and the first hit wins:

  1. JACKDAW_SDK_DIR, if set. Usually an installed layout: an sdk/manifest.txt with the rustc wrapper, Cargo.lock, and toolchain.txt beside it. A bootstrap cache directory or a jackdaw checkout is accepted too, so pointing it at any of the three works.
  2. A dev checkout’s own target/<triple>/, when the SDK there is built. An in-tree SDK beats any cache, because a debug editor and a release cache are not link-compatible.
  3. The same installed layout next to the running executable. This is what a downloaded bundle uses, with no env var.
  4. The bootstrap cache at ~/.jackdaw/sdk/<version>-<toolchain>/ (or under $XDG_DATA_HOME when that is set to an absolute path). Written by first-run setup and keyed by jackdaw version and toolchain, so an upgrade lands in a fresh directory and the old one is reclaimed.

jd doctor reports which of these won, whether the prerequisites for building it are in place, and whether the resolved SDK is actually usable; jd setup builds it.

A missing library or rustc wrapper stops a build before it compiles anything, rather than minutes in, and names what to do about it:

[fail] SDK: explicit JACKDAW_SDK_DIR at /opt/empty/sdk/.../libjackdaw_sdk.so is not usable
       no SDK library at /opt/empty/sdk/x86_64-unknown-linux-gnu/libjackdaw_sdk.so
       no rustc wrapper at /opt/empty/jackdaw-rustc-wrapper
       fix: unset JACKDAW_SDK_DIR to use the SDK this jackdaw found for itself

Cargo features

These are features of the jackdaw crate itself, relevant if you build the editor from source. Projects have no jackdaw-related features.

  • default = ["multiplayer", "camera_rig", "embed-recipe"].
  • multiplayer. Bundles the editor-only networking authoring extension. The editor writes replication metadata; no lightyear is compiled into it.
  • camera_rig. The authorable camera-rig components.
  • dylib. The SDK-backed extension flow: builds the proxy dylib that extension builds link against. On by default in precompiled releases because loading native extensions in-process requires sharing the SDK’s type graph. Source builds opt in explicitly with --features dylib.
  • embed-recipe. Bakes the SDK-builder recipe into the binary so a packaged, source-free jackdaw can build its own SDK on first launch. On by default for self-contained Cargo installs.

Building with dylib needs an explicit --target <host-triple>, so the editor links the same SDK the build pipeline compiles extension dylibs against.

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.
  • keymap_preset.json: the selected keymap preset.
  • last_new_project_location: the folder the New Project dialog opens in.
  • extensions.json: desired enabled/disabled state.
  • trusted_publishers.json: publisher keys accepted through the native-code trust prompt.

Signed .jdext payloads live in the platform data directory under jackdaw/extensions/<id>/<version>/. active.json selects one version and garbage.json queues retired mappings for deletion on the next launch. Loose dylib search directories and their environment variables are unsupported.

Project file

.jackdaw/project.json (see BSN Format) holds project-scoped editor settings:

  • last_open_tabs: scene paths, relative to the project root, restored in order on the next open. last_active_tab indexes into it and is clamped on 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.
  • default_scene: reserved. The field is read and written, but nothing currently opens a scene from it; tab restore plus the assets/scene.bsn fallback decide what opens.

Custom editor composition

Programmatic configuration goes through jackdaw_editor and its JackdawEditorPlugins plugin group:

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

Notes:

  • EnhancedInputPlugin must be added before JackdawEditorPlugins.
  • DylibLoaderPlugin is intentionally not in the group. The official GUI opts into marketplace loading separately.

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

Toolchain

The repo ships a rust-toolchain.toml pinning nightly-2026-03-05, and CI uses the same channel. The SDK is pinned to that exact rustc: extension builds and the SDK have to share one compiler for the shared type graph to line up, so setup installs it through rustup rather than using whatever is selected.

This affects the editor and the extensions it builds in-process. Your game’s own cargo build and cargo run use your own toolchain, untouched.