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
- Discord: discord.gg/S9k2HRwc. The fastest way to ask a question or share a screenshot.
- GitHub:
jbuehler23/jackdaw. Source, issue tracker, and this book (underbook/).
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 precompiled release, a Cargo installation, and a source
checkout. All three provide the GUI, jd, the rustc wrapper, project
scaffolding, and import.
Jackdaw versions track Bevy minors: Jackdaw 0.19 targets Bevy 0.19, and so do
the jackdaw_* crates your project depends on. Nightly builds track Bevy’s
main branch; see Stable and Bleeding Edge.
Which one to use
| Extension SDK | |
|---|---|
| Precompiled release | ships prebuilt |
cargo install | built on demand, only for extensions |
| Source checkout | built from the checkout, for extensions |
Take a precompiled release if one exists for your platform; the installer is the quickest way to get one. Games never need the SDK; see The extension SDK.
Prerequisites
Every install path needs these, because your game compiles on your machine.
jd doctor checks each one and prints the fix for anything missing.
Linux
- rustup and Cargo, from https://rustup.rs.
- A C and C++ compiler:
build-essential(Debian, Ubuntu),base-devel(Arch) orgcc-c++(Fedora). - pkg-config and Bevy’s system libraries: the alsa, udev, wayland and x11 development packages. See Bevy’s Linux dependencies for your distribution. On Debian or Ubuntu:
sudo apt install build-essential pkg-config libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev libx11-dev
Windows
- rustup and Cargo, from https://rustup.rs.
- Visual Studio Build Tools with the Desktop development with C++ workload, which provides the linker Rust needs: https://visualstudio.microsoft.com/visual-cpp-build-tools/.
macOS
- rustup and Cargo, from https://rustup.rs.
- The Xcode Command Line Tools:
xcode-select --install.
Check
jd doctor
See Troubleshooting for how to read the report.
Install script
The install script downloads the release archive for your platform, checks
it against its .sha256, and installs it for your user, without admin
rights. It supports x86-64 Linux, Apple Silicon macOS and x86-64 Windows.
Linux and macOS:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/jbuehler23/jackdaw/releases/latest/download/install.sh | sh
Windows (PowerShell 5.1 or 7):
irm https://github.com/jbuehler23/jackdaw/releases/latest/download/install.ps1 | iex
Then open a new terminal and run jackdaw, or jd doctor to check the
prerequisites.
What it changes
| Linux, macOS | Windows | |
|---|---|---|
| Release folders | ~/.local/share/jackdaw/install/<version>/ | %LOCALAPPDATA%\jackdaw\install\<version>\ |
| Active version | current symlink beside them | current directory junction beside them |
| Commands | jackdaw and jd in ~/.local/bin | ...\install\current on the user PATH |
PATH | a line in ~/.profile, ~/.bashrc, ~/.zshenv and a fish conf.d file, if ~/.local/bin is not already on PATH | the user PATH in the registry |
Each release folder is a complete archive folder, kept whole: the editor
finds its runtime libraries and SDK next to its own executable. On Linux
~/.local/bin holds symlinks. On macOS it holds small scripts that start
the real binary, because macOS reports a program started through a symlink
by the link’s path, where there is no SDK.
XDG_DATA_HOME moves the Linux and macOS install along with it. The
installer does not touch your projects, settings, extensions or SDK cache.
Options
Pass options to the piped script after sh -s --, for example:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/jbuehler23/jackdaw/releases/latest/download/install.sh | sh -s -- --no-modify-path
install.sh | install.ps1 | Environment | Effect |
|---|---|---|---|
--version <v> | -Version <v> | JACKDAW_VERSION | Install release v<v>, such as 0.19.0-rc.9 |
--prefix <dir> | -Prefix <dir> | JACKDAW_INSTALL_DIR | Keep release folders in <dir> |
--bin-dir <dir> | JACKDAW_BIN_DIR | Put the commands in <dir> | |
--no-modify-path | -NoModifyPath | JACKDAW_NO_MODIFY_PATH=1 | Leave shell files and PATH alone |
--yes | Do not ask before installing | ||
--uninstall | -Uninstall | Remove what the installer added |
install.sh asks for confirmation only when run from a file in a terminal;
piped, it does not ask. iex cannot pass parameters, so on Windows run the
script as a script block instead:
& ([scriptblock]::Create((irm https://github.com/jbuehler23/jackdaw/releases/latest/download/install.ps1))) -Version 0.19.0-rc.9
releases/latest is the newest final release. Release candidates carry the
installers too: take them from the release’s own tag, for example
https://github.com/jbuehler23/jackdaw/releases/download/v0.19.0-rc.9/install.sh,
and pass --version.
Upgrade, roll back and uninstall
Run the installer again to upgrade. It installs the new version beside the
old one, switches current to it, and keeps the version it replaced for
rollback; older versions are removed. To roll back, run it with
--version <previous>. On Windows, close Jackdaw before upgrading.
To uninstall, run it with --uninstall (-Uninstall on Windows), with the
same --prefix if you set one. It removes the release folders, the commands
and the PATH lines it added.
Precompiled release
Tagged releases provide
archives for x86-64 Linux, x86-64 Windows, and Apple Silicon macOS, each with
a .sha256 checksum and a build provenance attestation. Intel macOS users
install with Cargo or from source.
To install an archive by hand instead of with the
install script, read on. Each archive holds one folder with jackdaw, jd, jackdaw-rustc-wrapper,
their runtime libraries, and the prebuilt SDK. Keep the folder together, and
add it to your PATH to use jd from a terminal.
To check an archive’s provenance:
gh attestation verify <archive> -R jbuehler23/jackdaw
The binaries are not code-signed yet, so Windows and macOS ask before running them.
Linux
Download jackdaw-x86_64-unknown-linux-gnu.tar.zst, then:
tar -xf jackdaw-x86_64-unknown-linux-gnu.tar.zst
./jackdaw-x86_64-unknown-linux-gnu/jackdaw
Extracting a .tar.zst needs the zstd tool, which most distributions ship;
install it from your package manager if tar reports it missing.
The archive needs glibc 2.31 or newer, as on Ubuntu 20.04 or Debian 11. On
older distributions, use cargo install or a source checkout instead.
Windows
Download jackdaw-x86_64-pc-windows-msvc.zip, extract it, and run
jackdaw.exe from the extracted folder. SmartScreen shows “Windows protected
your PC” the first time: choose More info, then Run anyway.
macOS
Download jackdaw-aarch64-apple-darwin.zip and extract it (double-click it
in Finder, or use unzip). Then clear the quarantine flag macOS puts on
downloaded files, which otherwise blocks the unsigned binaries and their
libraries:
unzip jackdaw-aarch64-apple-darwin.zip
xattr -dr com.apple.quarantine jackdaw-aarch64-apple-darwin
./jackdaw-aarch64-apple-darwin/jackdaw
Cargo install
cargo install jackdaw jd --locked
Any stable Rust from 1.95 on builds the editor. The jackdaw package
provides the editor and jackdaw-rustc-wrapper, and the jd package
provides jd; install both, and no other workspace packages individually.
The build needs about 10 GB of memory at Cargo’s default parallelism. With
8 GB, close other programs and add --jobs 2, or take the release archive.
Cargo installs are self-contained; use a precompiled release to load signed native extensions.
Source checkout
git clone https://github.com/jbuehler23/jackdaw
cd jackdaw
cargo run --bin jackdaw
The checkout pins its compiler in rust-toolchain.toml. A debug build of the
editor needs more memory than an install; on a 16 GB machine, build with
--jobs 4, or set jobs = 4 under [build] in ~/.cargo/config.toml.
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.
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')"
Managing editors with jd
jd editor installs editors side by side, one per version, so projects
made with different Bevy versions each open in a matching editor.
jd editor list --available # published versions, by Bevy line
jd editor install 0.20 # newest 0.20.x
jd editor install bleeding-edge # newest nightly, built against Bevy main
jd editor install 0.20.1 --modules physics,navmesh
jd editor list # what is installed
jd editor default 0.20.1 # editor for projects that pin none
jd editor remove 0.19.0
jd editor clean # drop nightlies no project uses
jd editor verify 0.20.1 # check that an install still runs
jd editor refresh # fetch the release index into the cache
Every jd editor command takes --json, which prints one JSON object per
line (progress, stage, message, warning, index, done and error events)
for programs that drive jd, such as the editor’s launcher.
Versions come from a signed release index, published from the first
release that ships it on; earlier releases are not in it. jd checks the
signature and each download’s size and checksum, and runs the new editor
once before keeping it. A failed install leaves nothing behind. The index is
cached, so listing works offline. jd only reports newer versions; it never
downloads anything you did not ask for.
jd open <project> reads the editor the project’s jackdaw.toml pins and
launches the newest installed patch of that line, or tells you which
jd editor install to run. jd editor remove refuses while a project
last opened with jd open still uses that editor; --force removes it
anyway. Nightlies stay installed until jd editor clean, which keeps the
newest, those marked known good, and any a project opened with jd open
uses. Projects the launcher opens are recorded the same way.
The launcher, the editor’s first window, shows the same installs: its
Editors page installs, updates, verifies and uninstalls them through jd,
and opening a project from its Projects page relaunches into the install
the project pins, or offers to install it, open a copy with an installed
editor, or convert the project after a backup.
Editors live in editors/ under the data directory
(~/.local/share/jackdaw on Linux, %LOCALAPPDATA%\jackdaw on Windows),
next to installs.json. An editor installed another way, such as
with cargo install, can be added with jd editor locate <path>; removing
it later only unregisters it.
jd self update replaces a standalone jd with the one from the newest
stable release. A jd installed with cargo is updated with
cargo install jd --locked, and one that came with an editor, from the
install script or jd editor install, is updated with that editor.
The extension SDK
The extension SDK is a full compilation of Bevy and the Jackdaw API that native editor extensions link against. Games do not use it: a game builds as an ordinary cargo binary against its own Bevy, and the editor reads its component types from that binary. A game’s first build compiles Bevy, like any Bevy project.
A release archive ships the SDK prebuilt. A Cargo install builds it the first time you open an extension project, or when you press Build SDK on the launcher. That takes 20-30 minutes on a typical machine, once per Jackdaw version. To do it from a terminal instead:
jd setup
The SDK builds with the same Rust release that built the editor. Jackdaw
installs that release through rustup the first time it is needed, without
changing your default; jd doctor names it under editor toolchain.
A built SDK is cached in jackdaw/sdk/ under the local data directory:
~/.local/share on Linux, ~/Library/Application Support on macOS, and
%LOCALAPPDATA% on Windows. See
Where the SDK lives.
jd doctor reports which SDK is in play:
[ ok ] SDK: release bundle (/opt/jackdaw/sdk/x86_64-unknown-linux-gnu/libjackdaw_sdk.so)
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, --modules <list>
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; see Migrating an Existing Project.
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.
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.
Stable and Bleeding Edge
Jackdaw ships on two channels.
| Stable | Bleeding Edge | |
|---|---|---|
| Bevy | a release, such as 0.20 | the main branch, at one commit |
| Versions | 0.20.0, 0.20.1, … | nightly-2026-10-18, one per night |
| Crates | on crates.io | from git, at the nightly’s commit |
| Install | jd editor install 0.20 | jd editor install bleeding-edge |
| Kept | until you remove it | 30 days, longer for known-good ones |
Use Stable for anything you intend to ship. Bleeding Edge is for trying a Bevy feature before it is released, and for helping find what Bevy’s main branch breaks before a release does.
Branches
release-0.20carries Jackdaw 0.20.x on Bevy 0.20. Fixes land onmainfirst and are backported, and each 0.20.x release is tagged there.maintracks Bevy’smainbranch. Every Bevy dependency is{ git = "https://github.com/bevyengine/bevy", branch = "main" }, andCargo.lockpins the commit. A pull request builds against the commit in the lockfile;cargo update --package bevymoves it.
When Bevy releases its next minor, main moves to it from crates.io, a new
release- branch is cut, and main goes back to tracking Bevy’s main.
Nightlies
Every night the Nightly workflow
moves main’s lockfile to Bevy’s newest commit, runs the tests and builds
the bundles for every platform. Only a green night is published: as the
GitHub prerelease nightly-YYYY-MM-DD, and in the release index jd
installs from, with the Bevy and Jackdaw commits it was built from. A
Monday nightly is marked known good and kept for six months. The newest
nightly is never removed, even when later nights fail.
Bleeding Edge projects
A project created with a nightly records it in jackdaw.toml:
[jackdaw]
version = "0.21.0-dev"
bevy = "main"
channel = "bleeding-edge"
build = "nightly-2026-10-18"
bevy_rev = "85503941e488152711a4695bbe248e9ed9f453fc"
and asks for Bevy’s main branch in Cargo.toml, as Jackdaw’s own crates do:
bevy = { git = "https://github.com/bevyengine/bevy", branch = "main" }
jackdaw_runtime = { git = "https://github.com/jbuehler23/jackdaw", rev = "...", features = [...] }
The editor’s types and its extension SDK are built from one Bevy commit, so
the project has to build against that commit too. Cargo cannot be given a
rev beside the branch Jackdaw’s crates name without building a second Bevy,
so the commit lives in Cargo.lock: before each build the editor moves the
lockfile to bevy_rev if it has drifted. To move a project to a newer
nightly, open it with that nightly; to do it by hand, run
cargo update --package bevy --precise <commit>.
jd open opens a Bleeding Edge project only in the nightly it records, and
names the jd editor install to run when that nightly is not installed.
Reporting breakage
A red nightly opens an issue labelled
bevy-main,
naming the Bevy commit and the failing step, and the next green night closes
it. If a published nightly is broken for you, open an issue with that label
and include:
- the nightly (
jackdaw --versionprints it, with the Bevy commit), - what you did and what happened, with the log if there is one,
- whether the previous nightly, or the latest known-good one, works.
A maintainer can take a broken nightly out of the channel from the Nightly
workflow’s broken input; jd editor install bleeding-edge then picks the
newest one that is not marked broken.
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-gamefrom the terminal). You get a normal Bevy crate: alib.rswith aGamePlugin, amain.rsthat runs the standalone game, a starter scene, and ajackdaw.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 Components panel 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:
- In the Outliner panel, click Add Entity and pick
Cube. - The cube appears at the origin. Click it in the viewport or in the Outliner to select.
- With the cube selected, drag a translation arrow on the
gizmo. The default mode is translate; press
Rfor rotate,Tfor scale,Escto 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 Components panel 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:
- Viewport Navigation for getting around the 3D view.
- Custom Components to attach your own behaviour to the cube.
- Migrating an Existing Project if you already have a Bevy game and want to wire jackdaw into it.
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 lockfile, toolchain, or ordinary target/.
Runtime wiring
The same preview wires in the runtime the editor talks to. Component discovery and Play need all three pieces:
jackdaw_runtimein the game package’s[dependencies], with thephysicsandpiefeatures. An existing plain declaration only gainspie. The manifest is edited in place, keeping its comments and layout.app.add_plugins(jackdaw_runtime::JackdawPlugin)in the game plugin’sbuild.DefaultPluginspassed throughjackdaw_runtime::maybe_windowlessinsrc/main.rs, so Play can show the game inside the editor.
Each piece already present is left alone, so running the import again finds nothing to do.
When import leaves it to you
Import does not edit anything it cannot change safely. It lists the step as a
note: in the preview instead, and jd doctor --project reports it as
[fail] until it is done:
jackdaw_runtimedeclared as an optional, renamed, or target-specific dependency. Import leaves the manifest and your sources alone. Make it a plain[dependencies]entry and runjd importagain.- No single plugin to edit: more than one
impl Pluginfor the game plugin, or abuildit cannot edit. - A
main.rswithout anApp::new()...run()chain that addsDefaultPluginsitself. - A project on another Bevy minor, imported with
--allow-bevy-mismatch.
The manual steps:
cargo add jackdaw_runtime@0.19 --features physics,pie
#![allow(unused)]
fn main() {
// in your game plugin
fn build(&self, app: &mut App) {
app.add_plugins(jackdaw_runtime::JackdawPlugin);
}
}
#![allow(unused)]
fn main() {
// in src/main.rs
let default_plugins = jackdaw_runtime::maybe_windowless(DefaultPlugins);
App::new().add_plugins(default_plugins)
}
If the game builds its App somewhere other than src/main.rs, pass
DefaultPlugins through maybe_windowless there; jd doctor then reports
embedded Play as [warn] not checked.
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.
A jackdaw_runtime feature that no longer exists, such as physics, is
dropped, and the module that replaced it is recorded under [modules] in
jackdaw.toml. jd doctor reports such a feature and the launcher offers the
update when the project is opened.
Bringing asset references up to date
A project written before assets were files at paths spells a material or any
other asset by a bare name (@grass), keeps entries in assets/catalog.bsn,
carries asset files with no header naming their type, and holds terrain
sidecars at an older format version. All of that still loads. One operator
writes it out in the current spelling:
project.migrate_asset_references
It rewrites every name a scene or a prefab spells for an asset as the path of
the file that answers to it, writes each catalog.bsn entry out as a file of
its own and leaves the catalog empty, puts the header naming its type on every
asset file that has none, and re-encodes every terrain sidecar with its
material slots as paths. It reports what it rewrote and what it left alone: a
name two files carry, which stands for neither, and a name no file carries,
such as a material that was never saved.
The operator writes over the project’s files and undo does not reach them, so it refuses to run while anything open has unsaved edits. Running it a second time reports that there is nothing to migrate.
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.
Troubleshooting
Start with jd doctor
jd doctor # the machine
jd doctor --project /path/to/game # the machine and one project
Run inside a project, jd doctor checks that project too. Every line starts
with a tag:
[ ok ]: fine.[info]: worth knowing, nothing to do. A game project reports a missing extension SDK this way, because games never use it.[warn]: builds can go ahead, but something may misbehave. Read thefix:line under it.[fail]: builds will fail until thefix:line under it is done.
The last line says all checks passed or some checks failed, and the
command exits non-zero on any [fail].
Installing
A compiler, MSVC, or the Xcode Command Line Tools
[fail] C/C++ compiler, [fail] MSVC build tools or
[fail] Xcode Command Line Tools means a native build tool is missing, and
every game build will fail. Install it as described under
Prerequisites, open a new terminal so PATH
is refreshed, and run jd doctor again.
Rust without rustup
[fail] rustup or [fail] Rust toolchain: ... without rustup means Rust
came from somewhere other than rustup, such as a distribution package.
Jackdaw installs the toolchain extensions need through rustup, so install
rustup from https://rustup.rs.
Linux development packages
[warn] pkg-config or [warn] Bevy system libraries means Bevy’s audio and
input crates will not find the system libraries they link. Install Bevy’s
Linux dependencies.
glibc too old for the Linux archive
The Linux archive needs glibc 2.31 or newer. On an older system the editor
fails to start with an error such as
version `GLIBC_2.30' not found. Install with cargo install instead.
macOS says the app cannot be opened
The release binaries are not code-signed yet. Clear the quarantine flag on the extracted folder:
xattr -dr com.apple.quarantine jackdaw-aarch64-apple-darwin
Windows SmartScreen blocks the editor
Choose More info, then Run anyway.
Running the editor
Black window or crash on start on Linux
On Wayland the editor uses a transparent window. A surface that cannot blend alpha, which some older GPUs and drivers report, falls back to an opaque window. If the window is still black or the editor stops with a surface error, force the opaque window:
JACKDAW_OPAQUE_WINDOW=1 jackdaw
X11 sessions always get the opaque window.
Components do not show up, or Play times out
Run jd doctor --project <path>. A [fail] under runtime dependency,
runtime feature, runtime plugin or embedded Play means the runtime wiring is
incomplete; see
Runtime wiring.
[warn] project types: not built yet means the editor has not read the game’s
types; run jd build or Rebuild Project.
Building
New project inside another cargo workspace
A project created inside another cargo workspace declares its own
[workspace] so it builds standalone, but that can break the outer
workspace. Jackdaw warns and names both options: add the line it prints to
the outer workspace’s Cargo.toml, for example
exclude = ["games/my-game"]
or delete the [workspace] table from the new project’s Cargo.toml to make
it a member.
Dependencies do not resolve
[fail] dependencies shows cargo’s first error. When a new project’s crates
do not resolve, the warning names the source they were requested from:
crates.io at a version, a git revision, or a local checkout. An editor built
from git or a checkout scaffolds projects that depend on that same source,
so they need network access to the repository, or that checkout on the same
machine. Once the source is reachable, run cargo update if a release is
newer than your cached index, then jd doctor --project <path> again.
SDK is older than the editor
In a source checkout, the editor and its SDK are built by separate commands
and can drift apart. An extension build then warns that the SDK is older
than the editor running it, and may fail to link. Rebuild the SDK with the
command in the warning, or use the SDK from jd setup.
SDK is not usable
[fail] SDK: ... is not usable lists what is missing, then a fix. If
JACKDAW_SDK_DIR is set, unset it to use the SDK Jackdaw finds for itself;
otherwise run jd setup. See
Where the SDK lives.
Extension built by an older Jackdaw
The log warns that an extension “exports no jackdaw_register_types; it was
built by an older jackdaw”. It still loads, but rebuild it with the current
Jackdaw (jd build in the extension project, then jd extension pack) so its
types reach the editor on every platform.
“The filename or extension is too long” on Windows
Older versions could fail a build with this error (os error 206). The rustc wrapper now passes long command lines through a file. If you still see it, report it with the editor log.
Logs
-
Windows release:
%LOCALAPPDATA%\jackdaw\logs\editor.log. The previous run’s log is kept beside it aseditor.previous.log. -
Linux and macOS: the editor logs to the terminal. Start it from one to keep the log:
jackdaw 2>&1 | tee jackdaw.log -
An SDK build started from the editor writes
setup.login its SDK cache directory,jackdaw/sdk/<version>-<toolchain>/under the local data directory.
Reporting a bug
Open an issue at https://github.com/jbuehler23/jackdaw/issues with:
- the output of
jackdaw --version, - your OS and GPU,
- the full output of
jd doctor --project <path>, - the editor log,
- the steps that lead to the problem.
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/Dmove along the view direction.Q/Emove down and up in world space.Shiftdoubles 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+1throughCtrl+9saves the current camera pose to a slot.1through9restores 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+Wtoggles wireframe.[and]step the grid size down and up. Numbers print in the status bar.Ctrl+Alt+Scrollis 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 camera’s look sensitivity. 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
Outliner panel, click Add Entity and pick Cube or 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:
Clickplaces a vertex.Backspaceremoves the last vertex.Entercloses the polygon.Escor right-click cancels.Tabtoggles between additive and subtractive draw mode. In subtractive mode (Cto 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:
1vertex mode2edge mode3face mode4clip 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
Ctrlwhile dragging toggles snap to grid; the snap step follows your current grid size.X/Y/Zconstrain the drag to that axis.MMBtoggles the global snap mode without holdingCtrl.
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 Components panel. 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 Components panel 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 Project window and the Material Browser. Earlier builds had a separate texture browser and a separate file tree, and both were folded into the Project window.
Project window
The bottom panel by default. Shows the project’s assets/
directory as a folder tree on the left and a tile grid of the
selected 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 select it; the Components panel shows its card, with the picture, what the header says it holds, an Apply button, and layer steps for a KTX2 array.
- Narrow the grid with the kind menu (scenes, prefabs, materials, definitions, images, audio) or the search box.
- Double-click an image to apply it to the selection. This
routes through the
material.apply_textureoperator, so it goes on the undo stack. - Drag a
.glbinto the viewport to spawn a model entity. - Drag an image into the viewport to place it as a reference image.
- Drag a
.bsninto the viewport to spawn an instance, or double-click it to open it in a tab. - Drop new files into
assets/from your file manager. The editor watchesassets/, 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 PBR materials: bundles of textures plus material parameters (metallic, roughness, normal strength, parallax). Use this when one texture isn’t enough, or when you want to share material settings across many brushes.
What it lists
Every material file the project holds, wherever it sits. The
editor indexes each .bsn under assets/ by what the file
says it holds, so the panel is a view of that index rather
than of one folder; there is no materials directory to point
it at. Beside those sit the materials that have no file yet,
marked unsaved: the texture sets detected under assets/, one
you made with New Material, and one whose file has gone.
Auto-detection
If you drop a folder of textures named consistently (e.g.
brick_albedo.png, brick_normal.png,
brick_roughness.png), the panel groups them into one
detected entry, wherever under assets/ they sit. The regex
driving detection is pbr_filename_regex in the
jackdaw_material crate; it recognises common suffixes
(_albedo, _diffuse, _normal, _n, _roughness, _r,
_metallic, _m, _ao, _height, _displacement).
A detected entry is a material you can edit and apply at once; Save Material writes it a file of its own, and from then on the panel lists it from the index rather than from the scan.
Saving
Save Material writes the material back to its own file,
or, for one that has none yet, to assets/materials/<name>.bsn.
Save Material As opens a file dialog on that same default
so you can put it anywhere under assets/; what you name the
file is what the material is called. A material that is edited
in the panel and already has a file is written back as the
edit lands.
An edit to a material with no file stays in memory, and a scene that uses it embeds it inline on save, so it keeps rendering outside this editor run.
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 a
.bsnfile of its own, in whatever folder you keep it in; the editor finds it by reading what the file holds. A save with no folder in mind puts it underassets/materials/. Any scene in the project can reference it, and references spell its path, such asmaterials/slate.bsn. Scenes written before paths spell@Nameinstead; they still load, and the next save writes the path.project.migrate_asset_referenceswrites every such name out as a path in one go.
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 save the material and edit its slots in the panel.
- Material disappears in the standalone build. Standalone
walks every
.bsnunderassets/and loads the ones holding a material. Scene-local materials still ship inline; a reference resolves to the file at that path, so a material file left out of the build falls back to a default.
Terrain
Jackdaw’s terrain is a heightmap-backed mesh, rendered as
clipmap LOD levels around the camera 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_clipmap_mesh_data.
Add a terrain
In the Outliner, click Add Entity and pick Terrain. You get a flat heightmap
component on a new entity, rendered as clipmap LOD levels
around the camera. Resolution and physical size are properties
on the Terrain component, editable in the Components panel.
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 LOD level when it finishes. Save before you click. There is no cancel button.
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.
A run stores its placements on the terrain rather than spawning an entity per instance, so nothing reaches the outliner and the group is one line in the sidecar. Re-running the same group replaces what it placed before, in a single undoable edit. A stored placement is a position, a yaw and a scale, so it stands upright; normal alignment applies only to a group that is still entities, and a run that stores its placements reports that it was skipped.
The Groups list shows every group under the terrain with its
placement count, and re-scatters or clears one. Adopt selected
group stores each direct model child of a hand-placed group as a
placement and removes the entities; one undo puts them back.
terrain.scatter.promote turns a single placement back into an
ordinary model entity for hand editing.
Stored placements draw as batched instances, one draw per model per
region, culled a region at a time, and ground cover stops drawing past
a distance. A navmesh bake stands an obstacle in for each placement
whose palette entry blocks agents, so scatter needs no
NavmeshExclude tagging; a placement whose model has not loaded gets a
default footprint, counted in the bake summary.
The bake is the Navmesh tool in the terrain palette, from the Navmesh
module’s extension (jackdaw.navmesh). It writes <scene>.jdnav beside
the scene, which the game reads with jackdaw_runtime’s navmesh
feature.
Sidecars and export
Scene files keep the small terrain descriptor, while heights,
painted texture ids and per-cell channel values are stored beside
the scene in versioned .jdterrain files, along with the terrain’s
texture-set reference. Save and move those sidecars with the .bsn
scene that references them.
Each material slot holds the path of the material file it draws,
such as materials/slate.material.bsn. A sidecar written before
slots held paths spells a bare name; opening the scene points each
name at materials/<name>.material.bsn where that file is there,
and leaves it a name where it is not, saying which slot it left.
A sidecar in an older format migrates when the scene opens and is rewritten in the current format on the next save. A sidecar this build cannot read, such as one written by a newer jackdaw or one whose resolution is not a power of two, refuses edits and is never overwritten, so nothing is lost while you fix or replace it.
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.pngis a 16-bit grayscale PNG. Every pixel decodes to a world-space height viaheight = manifest.heightmap.base_m + pixel * manifest.heightmap.step_m(encoding: "unsigned-steps-from-base"). Quantized exports setstep_mto the terrain’s elevation step; unquantized exports derivestep_mfrom the actual authored height span (not frommax_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"), andpalette– a list of{ value, label, color }entries,coloras#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 ownformat_version: 1, lists every scattered / placed instance:name,asset(nullable),translation_m/rotation_quat/scale, andcomponents(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-endianf32, row-major, unquantized and unscaled – for a consumer that wants the source values rather than the quantized PNG encoding.
Rendering
Terrain draws as a handful of concentric LOD levels centred on the camera: the level under the camera samples every grid point, and each ring out doubles the step and covers four times the ground, so the vertex budget stays flat as the terrain grows. Levels snap their outer edge to the coarser level next to them, so boundaries between levels stay crack-free however far the camera moves. A level only draws where the terrain has data; ground no region owns costs nothing to render. Edits rebuild only the levels whose ground changed, which keeps sculpting fast on large heightmaps.
Each material slot has its own Tiling and Detiling controls, in the terrain panel’s Slot section. Tiling sets how many times the texture repeats per world unit; Detiling breaks up that repetition by turning and shifting individual tiles – 0 is off.
Autoterrain
Autoterrain textures the cells you have not painted from the slope under them: flat ground draws one of the terrain’s textures, steep ground another, and the band between them blends by height the same way two painted textures do. Turn it on in the terrain panel’s Textures tab, under Autoterrain, where you also pick which texture flat and steep ground draw and set the slope band in degrees. It is off per terrain until you turn it on.
Painting a cell claims it: from then on it draws what you painted, wherever the slope goes. With the paint options bar’s Restore Auto checkbox on, the brush hands the cells under it back to autoterrain without disturbing the paint underneath, so a later stroke over them brings back what they had.
Autoterrain is evaluated as the terrain draws, so sculpting re-textures the ground as you go: raise a bank past the slope band and it takes the steep texture as soon as it is steep. The settings live in the terrain’s sidecar, so a built game shades the ground the way the editor showed it.
Common gotchas
-
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_runtimedraws terrain behind itsterrainfeature, which is off by default so a game without terrain links neither the mesher nor the shader. Turn it on in your game’sCargo.toml:jackdaw_runtime = { version = "0.19", features = ["terrain"] }. With it on,JackdawPluginreads eachTerrainentity’s.jdterrainsidecar from beside the.bsnscene that spawned it and draws the result with the same splat material and clipmap mesher the editor uses. The sidecars have to ship alongside the scene (see “Sidecars and export” above): a missing one reads as flat ground, and one that will not decode draws nothing at all.Your game also has to have an active
Camera3d, because the LOD levels are laid out around wherever the terrain is being looked at from. An authored.bsnscene carries none, since the editor never saves its viewport camera into one, so spawn the camera yourself. With none in the world the terrain does not draw, andjackdaw_runtimesays so in the log once.By default the viewer is the active
Camera3dwith the highestorder, which in a single-camera game is the only one there is. If your game draws a UI overlay through a second camera at a higherorder, put theTerrainViewermarker component on your world camera; a marked camera is preferred over any unmarked one, so the overlay cannot pull the finest LOD ring away from the player.
Physics and prop placement
Jackdaw uses avian3d for
physics, through the Physics module. The module’s editor
extension (jackdaw.physics, from the jackdaw_physics crate) brings the tools on this page; the
game adds avian3d and jackdaw_avian_integration. Without the
extension, scenes keep their physics components and the
inspector shows them as components from a module that is not
loaded.
There’s no global “enable physics” toggle: you opt an entity in by adding the components it needs, and the 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’sColliderConstructor. Picks the collider shape (cuboid, sphere, trimesh-from-mesh, etc.) and rebuilds the actualColliderwhenever you change it.RigidBody: dynamic / static / kinematic. Dynamic bodies fall under gravity; static bodies are immovable collision surfaces.
Workflow:
- Select the brush (or any entity with a mesh).
- Components panel: click Add Component.
- Search “AvianCollider” and pick it (it lives under the Avian3d category).
- A collider with no
RigidBodyon its entity or above it stands as a static body, in the editor and in the game. Add aRigidBodyand pickDynamicorKinematicfor a body that moves. Enable Physics adds both, with aStaticbody.
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 Components panel 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
Platforms, walls, and floors need only a collider: without a
RigidBody they stand as static bodies. Static bodies don’t
fall, can’t be moved by forces, and serve as the collision
surface other bodies land on. New brushes and Enable Physics
author RigidBody::Static explicitly.
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
- Select the props you want to place (one or many).
- Press
Shift+Pto enter the Physics Tool. - The status bar reads
Physics Tool | drag selected to release | Space commit | Esc cancel. - Click and drag a selected entity. Release. Gravity takes over and the body falls / settles.
- Drag again to nudge.
- Press
Space(orEnter) to commit and exit. Settled positions are pushed onto the undo stack as a single entry, soCtrl+Zputs the bodies back where the tool found them. Escinstead ofSpacecancels 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
Viewmenu): show the body to collider parent / child links.
Common gotchas
- Dynamic body falls through the floor. The floor
entity has no collider, or its
RigidBodyis notStatic. AddAvianColliderto the floor, and remove itsRigidBodyor set it toStatic. - 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.
ColliderConstructorpanic when added directly. Picking the rawColliderConstructor(notAvianCollider) on an entity without aMesh3dpanics avian’s auto-init. The picker hides standaloneColliderConstructorfor this reason; pickAvianColliderinstead.- 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+Ssaves the current scene to its on-disk path. The first save prompts for a path; pick something underassets/.Ctrl+Oopens a scene in a new tab.Ctrl+Tcreates a new empty scene tab; it is unsaved until youCtrl+Sit.
The dialogs that reach for project files – opening and saving
scenes, picking a prefab, a reference image, a preview model or
a texture – start in the folder you are already looking at: the
Project window’s folder while it points inside the project,
otherwise the folder of the scene you have open, otherwise the
project’s assets/. Installing an extension bundle instead
starts where you picked the last bundle from, since bundles
live outside the project. Either way the folder you pick from
is recorded in .jackdaw/project.json and read back when you
reopen the project.
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
Fwith nothing selected (or with a known-visible entity selected) to reframe. File > Savegreys out. No scene is open. EitherFile > New Sceneor 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, useFile > 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.
That build goes to .jackdaw/target/game inside your project, never to
the project’s own target/. The editor builds the plain package, while
the build you run from your terminal usually carries features of your
own; keeping them apart means neither overwrites the other’s binary, at
the cost of compiling the dependencies once more. .jackdaw/ is
gitignored, so none of it is committed; a project that does not ignore it
should.
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.
Your own launch environment
A run config is committed, so it can only say what is true for everyone
working on the game. The variables that point a launch at a particular
server, or carry a sign-in token, or ask for an offline mode, are yours
alone. Those go in Play Settings, which the play.settings operator
opens:
- Environment is a line of
NAME=valuewords, added to every game the editor launches and overriding a run config’s own. - Arguments is a line of words, appended to the ones the run config names.
Both are kept with the project’s other editor preferences, under
.jackdaw/, which a project does not commit, and the editor never writes
or logs their values anywhere else. To set them without the dialog:
play.settings env="REALM=dev TOKEN=..." args="--offline"
Either parameter replaces what is held; naming neither opens the dialog.
The Game panel docks in the bottom dock area next to Assets. An embedded Play brings it to the front, since the game only streams while the panel is showing.
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+Sgo 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 Components panel 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, the panel that was in front before Play comes back, 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
Navigation
| Key | Action |
|---|---|
| RMB + Drag | Look around |
| WASD | Move (forward / left / back / right) |
| Q / E | Move up / down |
| Shift | Double speed |
| Scroll | Dolly forward / back |
| RMB + Scroll | Adjust move speed |
| F | Focus selected |
| Ctrl+1-9 | Save camera bookmark |
| 1-9 | Restore camera bookmark |
Selection
| Key | Action |
|---|---|
| LMB | Select entity |
| Ctrl+Click | Toggle multi-select |
| Shift+LMB Drag | Box select |
Transform
| Key | Action |
|---|---|
| Esc | Translate mode |
| R | Rotate mode |
| T | Scale mode |
| X | Toggle local / world space |
| M | Toggle snapping (same as the toolbar magnet) |
| MMB | Toggle snap |
| Ctrl (during drag) | Toggle snap |
| Arrows | Nudge (grid-unit move) |
| Alt+Arrows | 90 deg rotate |
| PageUp / PageDown | Nudge vertical |
Entity
| Key | Action |
|---|---|
| Delete / Backspace | Delete selected |
| Ctrl+D | Duplicate |
| Ctrl+C | Copy components |
| Ctrl+V | Paste components |
| H | Toggle visibility |
| Alt+G | Reset position |
| Alt+R | Reset rotation |
| Alt+S | Reset scale |
Brush Editing
| Key | Action |
|---|---|
| 1 | Vertex mode |
| 2 | Edge mode |
| 3 | Face mode |
| 4 | Clip mode |
| X / Y / Z | Constrain axis |
| Shift+Click | Multi-select |
| Delete | Delete selected element |
| PageUp/PageDown | Nudge selected vertices/edges/faces up/down |
| Enter | Apply clip plane |
| Esc | Exit brush edit |
Brush Draw
| Key | Action |
|---|---|
| B | Draw brush (add) |
| C | Draw brush (cut) |
| Tab | Toggle add / cut mode |
| Click | Place vertex / advance phase |
| Enter | Close polygon |
| Backspace | Remove last vertex |
| Esc / Right-click | Cancel drawing |
View
| Key | Action |
|---|---|
| Numpad 1 | Front view (orthographic, looking down +Y) |
| Numpad 3 | Right view (orthographic, looking down +X) |
| Ctrl+Numpad 3 | Left view |
| Numpad 7 | Top view (orthographic, looking down +Z) |
| Numpad 5 | Toggle perspective / orthographic |
| Home | Frame all entities |
| Ctrl+Shift+W | Toggle wireframe |
| [ | Decrease grid size |
| ] | Increase grid size |
| Ctrl+Alt+Scroll | Change grid size |
File
| Key | Action |
|---|---|
| Ctrl+S | Save scene |
| Ctrl+O | Open scene in a new tab |
| Ctrl+T | New scene tab |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo |
Play-in-editor
| Key | Action |
|---|---|
| Esc | Forward to the game (opens its in-game menu) while capturing |
| Shift+Esc | Release 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(JackdawEditorPlugins::default())
.run()
}
JackdawEditorPlugins pulls in everything jackdaw needs: the launcher,
viewport, hierarchy, inspector, brush tools, the Project window,
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::ProjectSelectis the launcher screen. Recent projects, new project, open existing.AppState::Editoris 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.rsis the resource and component layer.src/brush/mesh.rsrebuilds face meshes when the brush changes.src/brush/interaction.rsis 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.
A field holding a handle is an asset field: the row shows the path of the file behind it, with Pick, Clear and New beside it, and takes a file dragged onto it from the Project window. Pick lists the files the project holds of that field’s asset type, and offers the desktop’s file dialog for a type with no asset files of its own, such as an image. Every one of the three writes through the same undoable field edit a scalar row uses, so one choice is one history entry. A material’s texture slots are the same row.
A project whose schema spells a reference as the path itself rather than as a handle gets the row too: a string field of an asset whose value names a file the project holds is shown as that file, and every element of a list of them keeps the row even while it is empty, so a list of material paths is picked from rather than typed.
Code:
src/inspector/mod.rsis the dispatcher.src/inspector/component_picker.rsis the+ Add Componentflow.src/inspector/reflect_fields.rsrenders primitive fields.src/inspector/asset_row.rsrenders a field that names an asset.
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.bsnscenes and theEditorMeta/ReflectEditorMetareflect 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.bsnscene 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 pushEditorCommands here.jackdaw_terrain: heightmap data + sculpt + erosion.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-oncamera_rigfeature.jackdaw_csg: the glue between brushes and themanifold-rustmesh-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: networking authoring. The editor writes replication metadata only.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 on-demand 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 byjd. Release-only packaging is invoked throughcargo xtask.jackdaw_pie_protocol: the IPC message types and thejackdaw.tomlrun-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 (includingJackdawExtension). Itsdynamic_linkingfeature 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_apideliberately 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 separatebevy_dylibandjackdaw_dylibshared 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 itsjackdaw-rustc-wrapperbinary, which the editor’s build pipeline invokes to inject the right--externflags. 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.jsnformat. 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.
Modules
Integrations with crates built on Bevy live under integrations/, each in its
own workspace, so the editor’s workspace never resolves them.
integrations/physics:jackdaw_physics, the editor extension with the Physics tool, collider overlays and inspector toggles, andjackdaw_avian_integration, which builds avian colliders from a scene’s authored components in the game.integrations/navmesh:jackdaw_navmesh_editor, the editor extension with the terrain navmesh bake and its overlay, andjackdaw_navmesh, the navmesh generator it bakes with, from rerecast’s port of Recast. The game reads the baked file withjackdaw_runtime’snavmeshfeature and needs no crate of its own.
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_registerregisters the type when the schema extractor runs your game binary, so you don’t needapp.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_runtimeenables bevy’sreflect_documentationfeature, 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;
}
Asset types
A type your game registers as a reflected asset is a kind the editor can create and edit, one value per file:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
#[derive(Asset, Reflect, Default)]
#[reflect(Default)]
pub struct ItemDef {
pub stack_size: u32,
}
app.init_asset::<ItemDef>().register_asset_reflect::<ItemDef>();
}
Nothing is declared anywhere else. The schema the build extracts
reports the type and the editor names the kind after it (ItemDef
gives item, Item on menus).
There are three ways to create one, and none of them asks what
flavour of file to write. Right-click any folder in the
Project window and pick New Asset…: the list offers every
kind, searchable by its name or by the type it holds, and the one
you pick lands in that folder with its card in the inspector. The
Add menu lists the same kinds under Assets, creating in
the folder the Project window is showing. The
New beside an asset field in the inspector writes one of that
field’s type and assigns it. A name of its own is optional: without
one the file is <kind>_1.bsn, then <kind>_2.bsn.
The file is a plain .bsn whose second line names the type it
holds:
// jackdaw 0.19.0 | bevy 0.19
// jackdaw asset my_game::content::ItemDef
#torch
my_game::content::ItemDef { stack_size: 4 }
The header is how the Project window reads what a file is cheaply; the document’s own root is the truth, so a file written before headers existed still opens. A file can live in any folder.
References and enum payloads in an asset
One asset usually names another: a quest pays out an item, an
outfit wears a material. Spell the reference as a String and
mark it with @AssetRef, naming the asset type by its reflect
type path:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use jackdaw_scene_types::AssetRef;
#[derive(Asset, Reflect, Default)]
#[reflect(Default)]
pub struct QuestDef {
#[reflect(@AssetRef("my_game::content::ItemDef"))]
pub reward: String,
#[reflect(@AssetRef("my_game::content::ItemDef"))]
pub extras: Vec<String>,
pub objectives: Vec<Objective>,
}
}
The card gives reward the asset row it gives a Handle field:
the file it names, with Pick, Clear and New beside it, and a drop
target. Pick offers only the project’s items, and a drop of
anything else is refused and says so. On a Vec<String> the
attribute names what the elements hold, so every element gets the
same row. The row is there from the start, before the field names
anything.
Without the attribute a string field still gets the row once it names a file the project holds, which is how a field the editor knows nothing about still becomes editable; the attribute is what makes an empty one offer the right list.
An enum field is a menu of its variants, and a variant carrying fields brings its own rows with it:
#![allow(unused)]
fn main() {
#[derive(Reflect, Default)]
#[reflect(Default)]
pub enum Objective {
#[default]
Explore,
Kill { mob: String, count: u32 },
Reach(String),
}
}
Choosing Kill writes the variant with what its fields default
to and puts a row under the menu for each. In a Vec the list’s
add, move and remove controls sit beside each objective. Every
change is one history entry, and the file spells the variant the
way the type declares it:
my_game::content::QuestDef {
reward: "content/torch.bsn",
objectives: [
my_game::content::Objective::Kill { mob: "rat", count: 3 },
],
}
Viewport previews for markers
Tag a type with @EditorPreview and a gltf path under assets/
to see a viewport preview for your marker in the editor.
#![allow(unused)]
fn main() {
use jackdaw_runtime::prelude::*;
#[derive(Component, Reflect, Default)]
#[reflect(
Component,
Default,
@EditorPreview::gltf("models/player.glb"),
)]
pub struct PlayerSpawn;
}
If the entity already has a brush, a glTF, or a mesh, the overlay is skipped.
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
@EditorHiddensomewhere (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, and every document has a binary twin for the cases where that does not matter (see Binary form).
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.
The syntax is Bevy 0.20’s bsn! syntax, and the reader in
jackdaw_bsn::syntax follows the .bsn file format Bevy is
adding in its bevy_bsn crate, with a few additions that crate’s
docs list (comments, quoted names, generic type paths, list and
map values).
Binary form
The same document also writes as .bsb, a binary encoding of the
roots, patches and values the text holds, with the comment lines it
opens with carried as a field. It exists for two cases: a shipped
game, where nobody reads the assets, and a file so large that nobody
diffs it. Text stays the form a repository keeps.
Readers never go by the extension. A document is binary when its first
four bytes are the magic, whose leading byte is a UTF-8 continuation
byte that no text file can start with, so the two forms can never be
mistaken for each other. jackdaw_bsn::read_document sniffs and
returns either; read_document_text hands back .bsn text whichever
form the file was in. The comment lines a document opens with travel in
the binary form verbatim, so the asset header and the version stamp come
back on the text a conversion writes.
Writers go the other way: the extension picks the form, which is how a
save keeps a file in the form it was opened in. The editor’s file
context menus offer Convert to Binary and Convert to Text on one
document, each removing the other form once the new one is written and
refusing outright when a file already sits where it would write, and
project.export_binary writes a whole tree out as binary beside the
source, copying every other file through untouched. An export into the
source tree, or into a folder inside it, is refused rather than left to
walk over what it just wrote.
A pair is one asset. foo.bsn and foo.bsb are keyed by the .bsn
path, the text file wins when both exist, and a reference to
materials/grass.bsn resolves to grass.bsb when that is the only
form on disk. References are never rewritten by an export; the readers
that follow them resolve either form instead.
A game reaches the same pair through Bevy’s asset server.
jackdaw_runtime::JackdawAssetSourcePlugin, added before
DefaultPlugins, registers the default asset source with a reader that
retries the twin when the path as written is not on disk, so
asset_server.load("zones/zone1.bsn") and an AnimationGraphRef still
name the text path after an export has rewritten the tree. Its
file_path, processed_file_path and mode take the values the game’s
AssetPlugin carries, because the source registered first is the one
AssetPlugin keeps. A source of the game’s own goes through
with_document_twins for the same resolution. The retry costs one extra read only when the first one
misses, and a path in neither form fails with the ordinary not-found
error naming the path that was asked for.
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
]
#Namelabels the entity (itsNamecomponent); a name that is not an identifier is quoted,#"Main Camera".- A bare type path is a component at its default value.
Type { field: value, .. }sets struct fields; omitted fields keep their defaults.Type::Variantis an enum value;Type(value)a tuple struct.bevy_ecs::hierarchy::Children [ .. ]nests child nodes, and--separates sibling entities, there and between several roots.
Scenes written for Bevy 0.19 separated siblings with ,. They
still open: the reader rewrites that separator before parsing,
along with the type paths 0.20 moved, and the next save writes
the 0.20 form.
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
A material or any other asset file is referenced by its path
under assets/:
my_game::Signpost { board: "materials/slate.material.bsn" }
A scene-local asset, defined inline in the same .bsn file, is
referenced as #Name. The @Name spelling of a project-wide
asset is what files written before paths use; it still resolves
while a project catches up, as long as one file answers to that
name, and the next save writes the path.
Every reference resolves against the same table at load time: the project’s asset files under their paths, plus the scene’s own inline definitions. A reference that resolves to nothing is left as the file spelled it and falls back to a default handle rather than failing the load, so a missing material shows up as untextured geometry, not an error, and a save does not quietly drop what could not be found.
A reference no asset file answers to is loaded through the asset server as a file path, which is how an image, a mesh or any other file the engine loads for itself is named.
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
An asset file is a .bsn naming the type it holds, and it can
sit in any folder under assets/; the editor indexes every one
it finds by the path it sits at.
assets/catalog.bsn is how a project kept its named assets
before each had a file. It is read, never written: its entries
load under the @Name a scene spells them by, and opening a
project whose catalog still holds entries says how many and
points at project.migrate_asset_references, which writes each
one out as a file of its own. Legacy catalogs at
.jsn/catalog.jsn or assets/catalog.jsn are read the same
way. The game’s runtime reads the catalog too, so a project
that has not migrated yet still runs.
The suffixes jackdaw grew for itself are decoration now. A
material and an animation graph are asset files like any other,
known by the type their document holds, so either can sit in any
folder under any name: the Materials panel lists every material
the index holds, the Graph window opens every graph, and a game
loads one through the asset server by the path it sits at.
assets/materials/<name>.bsn and
assets/animation/<name>.animgraph.bsn are only where a save
with no folder in mind puts one.
Asset files in a game
The runtime reads the project’s asset files itself: at startup it walks every document under the asset folder, in either form, takes the type each file holds from its header or its first root, and loads the ones whose type the game registered as a reflected asset into that type’s store. A file is reachable by the path it sits at, and, while a project still spells references by name, by its stem where only one file carries that stem. A file naming a type the game has not registered is skipped with a warning; scenes and prefabs are left to the loaders that own them.
This is a walk rather than a Bevy AssetLoader for .bsn, because a
typed handle needs one loader per concrete asset type the game
registers, while the walk is generic over reflection and matches the
index the editor builds for the same folder. Loading a type
asynchronously through the asset server can come later without changing
how a file is written or referenced.
A game that keeps its definitions as rows rather than as assets reads the same files directly:
use jackdaw_bsn::{read_asset_file, walk_asset_files};
for (path, type_path) in walk_asset_files(Path::new("assets")) {
if type_path == ItemDef::type_path() {
let item: ItemDef = read_asset_file(&path)?;
items.insert(item.id.clone(), item);
}
}
walk_asset_files yields one entry per asset file with the type it
holds, leaving scenes and prefabs out. read_asset_file reads the
value the file’s first root holds, refusing a file whose root is
another type; fields naming other assets by path stay at their
defaults, since resolving those takes an asset server.
What is not in BSN
- Mesh data. Brushes serialize as their face planes; the mesh
rebuilds from those at load.
.glbimports reference the file path, not its contents. - Textures. References only.
- Editor-internal entities. Brush face entities, gizmo
helpers, picker panels, and similar carry an
EditorOnlyorNonSerializablemarker 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(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.
While developing, open the extension project in Jackdaw. The editor builds it
against the extension SDK, building the SDK first if this install has none, and
loads it. Rebuild Project builds and loads it again after a change, and so
does jd build in a terminal while the project is open, unless Hot Reload is
turned off in the File menu.
Adding Bevy plugins at launch
register runs against a live world, so it cannot add plugin groups,
schedules or system sets. An extension that needs them returns a Bevy plugin
from app_plugin, and the editor adds it to its App before the app runs:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use jackdaw_extension::prelude::*;
#[derive(Default)]
pub struct VehicleTools;
impl JackdawExtension for VehicleTools {
fn id(&self) -> String { "example.vehicle-tools".into() }
fn app_plugin(&self) -> Option<Box<dyn Plugin>> {
Some(Box::new(|app: &mut App| {
app.add_systems(FixedUpdate, simulate_suspension);
}))
}
fn register(&self, registrar: &mut ExtensionRegistrar<'_>) {
// windows, operators and menus that use what the plugin added
}
}
}
The plugin is added only for extensions enabled when the editor starts, both built-in ones and installed bundles. A plugin cannot be removed again, so:
- Installing or enabling such an extension while the editor runs marks it Restart required in the Extensions dialog. It activates on the next launch.
- Disabling it removes its windows and operators at once; its plugin keeps running until the editor restarts.
- Loading a new build of it reloads live when its plugin keeps the same
Plugin::name. The plugin added at launch stays in place; a renamed or newly added plugin waits for a restart. A closure plugin is named after the function that contains it. - An editor build of the open project cannot add a plugin at launch. Install the extension as a bundle once, restart, and later builds that keep the plugin reload live.
Guard plugins another extension may already have added, such as a physics
engine, with app.is_plugin_added::<T>(). jd extension pack records requires_restart
in the bundle manifest when the extension defines app_plugin, so tools can
show the restart without loading the library.
Input types
Bind an operator with bind_operator_host and a PresetInput; the keymap
attaches the binding and the user can rebind it. An extension that declares
its own actions takes the types from jackdaw_api::input: an entity with
InputContext holds actions (ActionOf), each action holds bindings
(BindingOf) with a Condition, and a firing action triggers Fire on its
entity. Since 0.20 the editor no longer uses bevy_enhanced_input, and
jackdaw_api::prelude no longer re-exports it; operators no longer implement
InputAction.
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.
An extension is built as a cdylib that links the editor’s SDK dynamically,
so it shares the editor’s Bevy and exports only its entry points, however
large its dependencies (a Windows DLL holds at most 65,535 exports). The build
embeds the crate hash of the SDK it was compiled against, and the editor opens
an extension library only when that hash is its own SDK’s. One built by
another Jackdaw, or by a Jackdaw from before the hash was embedded, is refused
with a message to rebuild it. 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.
Writing editor tooling
An extension that adds a tool to the editor, rather than a window, needs to
know what is selected, where the cursor points in the scene, and how to
change the scene so the change can be undone. jackdaw_api exposes those as
ordinary Bevy resources, components, system sets and operators. This chapter
walks through them. The Physics module in integrations/physics and the
Navmesh module in integrations/navmesh are complete examples.
What an extension can reach
| Need | Where |
|---|---|
| Whether a project is open | AppState |
| What is selected | selection::{Selection, Selected} |
| The viewport camera and the ray under the cursor | viewport::ViewportCursor |
| Entities the editor spawned for itself | EditorEntity |
| A modal tool, its toolbar button and its status line | tools |
| When brushes move and rebuild | brush::BrushSystems |
| Components new brushes start with | register_brush_default |
| Toggles in an inspector category’s header | register_inspector_chip |
| A row in the viewport’s overlay menu | register_viewport_overlay |
| What the open scene authored | document::SceneDocument |
| The open scene’s file, and when it opens, saves or moves | document |
| State each scene tab keeps its own of | register_scene_resource |
| The open scene’s terrain data | terrain::TerrainDataStore |
| A tool in the terrain palette, and its options bar | register_terrain_tool |
| Work the editor waits on before it is idle | BackgroundTask |
| Several changes as one undo step | op::with_history_span |
Everything registered through ExtensionRegistrar is a child entity of the
extension, so disabling the extension removes its toolbar buttons, chips,
overlay rows, brush defaults, terrain tools and scene resources with it.
Selection
Selection lists the selected entities, the last being the primary one, and
each selected entity carries Selected. Read either, or observe
On<Add, Selected> and On<Remove, Selected>. Change the selection through
the selection.select, selection.extend and selection.clear operators.
Changing the scene
The editor keeps a scene document beside the live world; that document is what gets saved and what undo restores. An extension that writes a component straight onto an entity changes the live world only, and the change is gone on the next load. Go through the editor’s operators instead:
component.addandcomponent.removetake anentityand atype_path;component.setsets a field, or with an emptyfieldthe whole component, from JSON;entity.set_transformmoves an entity.
Each pushes its own undo entry. To make several calls one step, wrap them:
#![allow(unused)]
fn main() {
use jackdaw_api::op::with_history_span;
with_history_span(world, "Enable Physics", |world| {
world
.operator("component.add")
.param("entity", entity)
.param("type_path", AvianCollider::type_path().to_string())
.call()?;
// ...
});
}
Do not call operators from inside another operator’s system. Record the
request in a resource and apply it from an ordinary system on the next
frame, as physics.enable does.
SceneDocument::authored_components(world, entity) lists the component type
paths the scene authored on an entity, as opposed to the companions
#[require] and plugins add at runtime. Use it to decide what to remove.
A modal tool
A tool is a modal operator: its system runs every frame while it returns
OperatorResult::Running. While any modal runs, the editor’s gizmo, numeric
entry and click selection stand down.
#![allow(unused)]
fn main() {
#[operator(id = "example.measure", label = "Measure", modal = true, allows_undo = false)]
fn measure(
_: In<OperatorParameters>,
active: ActiveModalQuery,
keys: ModalInputs,
cursor: ViewportCursor,
mut status: ResMut<ToolStatus>,
) -> OperatorResult {
if !active.is_modal_running() {
status.0 = Some("Measure | Esc to stop".into());
}
if keys.cancel() {
return OperatorResult::Cancelled;
}
if let Some(ray) = cursor.ray() {
// ...
}
OperatorResult::Running
}
}
Register it with a toolbar button and a key:
#![allow(unused)]
fn main() {
ctx.register_operator::<MeasureOp>()
.register_toolbar_button::<MeasureOp>(Icon::Ruler)
.bind_operator_host::<MeasureOp>([PresetInput::key("KeyM")]);
}
The button lights while the tool runs. ModalInputs reports the editor’s
confirm (Enter) and cancel (Esc) keys. The editor clears ToolStatus once
no modal runs. A tool that captured a viewport when a drag began can keep
following it with ViewportCursor::ray_for(camera, viewport, cursor).
When picking with MeshRayCast, filter out EditorEntity: material
previews and gizmo meshes sit at the origin and would hide the scene.
Brushes
Brushes are the Brush component in jackdaw_scene_types, evaluated with
evaluate_brush_geometry. The editor moves a brush’s origin to the centre of
its geometry in BrushSystems::Recenter and rebuilds its meshes in
BrushSystems::Rebuild; order systems that follow a brush’s shape after
them. register_brush_default gives every brush the editor creates from then
on a component, written to the document as if the person had added it.
Inspector and viewport rows
register_inspector_chip(category, label, type_path) puts a toggle in a
category’s header that adds or removes the component through the editor’s
operators. A brush shows the category even before it carries one of the
components.
register_viewport_overlay::<Op>(label, shown) adds a row to the viewport
overlay menu. Clicking it calls Op, which should flip its overlay and write
the new state to the row’s ViewportOverlay::shown.
Scene files
document::SceneFilePath names the file the open scene was opened from or
last saved to, None until an untitled scene is first saved. An extension
that keeps files of its own beside the scene, such as a bake, observes three
events:
SceneOpenedonce a scene is in front, opened from disk or brought back by a tab switch;SceneSavedafter the scene is written, which is when to write beside it;SceneRenamedwhen Save As or a rename gives the scene a new file.
Write with document::write_atomic, which goes through a temporary file the
way the editor writes scenes, so a crash leaves the old file whole.
State per scene
A resource registered with register_scene_resource::<R>() belongs to the
scene in front rather than to the editor. When another tab comes to the
front, the editor keeps this one’s value with its tab and brings back the
other’s; a scene opened or created in a tab starts from R::default().
#![allow(unused)]
fn main() {
ctx.register_scene_resource::<BakeState>();
}
Keep preferences that should survive opening another scene, such as whether an overlay is drawn, in an ordinary resource instead.
Terrain
terrain::TerrainDataStore holds the open scene’s terrain heights, material
list, paint and scatter, keyed by the data_path the Terrain component
carries. Read it; change terrain through the editor’s terrain.* operators.
The meshes the viewport draws a terrain with carry NavmeshIgnored and
EditorHidden, so tools gathering scene geometry can tell them from the
scene’s own.
register_terrain_tool::<Op>(icon) puts a button in the terrain tool
palette that calls Op. The operator picks the tool:
#![allow(unused)]
fn main() {
#[operator(id = "example.terrain_tool", label = "Example")]
fn pick(_: In<OperatorParameters>, mut mode: ResMut<TerrainEditMode>) -> OperatorResult {
mode.toggle(TerrainEditMode::Extension(PickOp::ID));
OperatorResult::Finished
}
}
While it is picked, the options bar above the viewport holds a node carrying
TerrainToolOptions. Observe On<Add, TerrainToolOptions>, or check for the
node each frame, and fill it with ui::spawn_scrub_chip, ui::spawn_checkbox,
ui::spawn_hint and buttons. The editor despawns it when the tool is put
away.
Background work
Spawn an entity carrying BackgroundTask while work runs off the main
thread, and despawn it when the work lands. Until then the editor is not
idle, and a remote wait for idle keeps waiting.
Comparison with other editors
Unity users will recognise Selection and Selection.selectionChanged,
EditorTool with its ToolManager, Undo.CollapseUndoOperations and
HandleUtility.GUIPointToWorldRay. Godot’s EditorPlugin covers the same
ground with get_selection, _forward_3d_gui_input,
add_control_to_container, inspector plugins and EditorUndoRedoManager.
Unreal’s editor modes, toolbar extenders and detail customisations are the
nearest match there.
The scene file events match Unity’s EditorSceneManager.sceneOpened and
sceneSaved, Godot’s scene_changed and scene_saved signals, and Unreal’s
FEditorDelegates::OnMapOpened and PostSaveWorld. A scene resource is
Godot’s EditorPlugin._get_state and _set_state, which hand a plugin its
state back per edited scene. Terrain tools follow Unity’s TerrainPaintTool,
which adds a tool and its settings to the terrain inspector, and Unreal’s
landscape mode tools. BackgroundTask is Unity’s Progress.Start and
Unreal’s async task notifications. Jackdaw differs in using plain ECS data for all of it:
registrations are entities, state is resources, and every change goes
through an operator.
Remote control and jd mcp
Every editor action is an operator, so the whole editor is scriptable through one
call. The editor serves the Bevy Remote Protocol on loopback while a project is
open. jd mcp runs jd-mcp, an MCP server that speaks MCP over stdio to the
client and BRP to the editor. Each tool is one BRP method, and every edit to the
open document lands on the editor’s undo stack. Undo does not reach the disk:
operators that save, export or bake are reachable here too.
Setting it up
Open a project in the editor, then register jd mcp with your MCP client as a
stdio server:
{
"mcpServers": {
"jackdaw": { "command": "jd", "args": ["mcp", "--project", "/path/to/my-game"] }
}
}
--projectnames the project root; without it, the working directory is used.- The editor is found through
<project>/.jackdaw/editor.json, written on open and removed on exit, so no port needs configuring. A client that starts first reports that no editor is running. - The port defaults to 15703, one past the game’s 15702. An editor that finds it
taken serves nothing; give a second editor its own port with
JACKDAW_REMOTE_PORT. - Remote control is on by default. Disable it per project with
{"remote": {"enabled": false}}in.jackdaw/settings.json.
Running the editor from a source checkout needs its libraries on the path, from the checkout root:
LD_LIBRARY_PATH=target/debug:target/debug/deps:$(rustc --print target-libdir)
rustc --print sysroot is not enough: <sysroot>/lib holds no libstd, which
lives in the target lib dir that command prints.
The tools
| Tool | What it does |
|---|---|
status | Project, open scene, dirty flag, selection, play state |
list_operators | Every operator with its parameter schema, filtered by prefix |
call_operator | Run one operator by id |
batch | Run several calls as one undo entry |
scene_tree | The scene as the outliner shows it, from a named root |
get_entity | One node and its descendants as BSN text |
apply_bsn | Spawn BSN text, optionally under a named parent |
scene_bsn | The whole open document as BSN |
open_scene | Open a scene by its assets-relative path |
save_scene | Write the open scene to its file |
select | Select entities by name, and frame them |
screenshot | Aim the camera, capture the viewport or the window, return the PNG |
wait | Let frames pass, or wait for a state the editor has reached |
cancel | End the modal operator holding the editor |
assets | Asset paths under assets/, by substring or * glob |
Two read-only resources: jackdaw://operators is the operator catalogue, and
jackdaw://scene is the open document as BSN.
save_scene and screenshot are the only tools that write to disk, though
call_operator reaches operators that save, export and bake. The server only
talks to loopback, and screenshot and scene paths must name a file inside the
project.
Working with it
Start from list_operators. Each parameter carries the same documentation the
editor’s tooltips show.
list_operators(prefix: "terrain.")
call_operator(id: "terrain.sculpt.stamp",
params: { terrain: "Ground", x: 12, z: -8, radius: 6, strength: 2 })
Parameters are coerced from the operator’s declared schema rather than the JSON
spelling, so radius: "6" reaches a float parameter and name: 7 a string one.
An Entity parameter takes a name or an entity id, which is how two entities of
one name are told apart; operators that act on the selection use it when nothing
is named.
Group calls that mean one action with batch – inside one span they are a
single undo entry:
batch(label: "Fence the north plot", calls: [
{ id: "entity.add.group", params: { name: "Fence_North" } },
{ id: "entity.place_gltf", params: { path: "kit/Prop_Fence_01.gltf", pos_x: 0, pos_y: 0, pos_z: 0 } },
])
A call answers with the entities it added under entities, so the next call can
name what the last one made. Every call in a batch reports its own, and
apply_bsn reports what its text spawned:
call_operator(id: "entity.add.cube") -> { entities: [4294967301], ... }
call_operator(id: "entity.set_transform",
params: { entity: 4294967301, x: 4, y: 0, z: -2 })
Placing something on the ground takes no guess at y. terrain.height reports
the surface under a point, entity.place_gltf stands a model on it when pos_y
is left out, and entity.snap_to_ground drops the selection, or the ids given as
entities, onto it as one undo entry.
prefab.spawn_instance takes a parent and joins the scene’s own root when it
is left out, so a placed instance does not stand beside the scene. scene.open
takes reload: a path whose tab is already open is activated in place, reread
when reload is set, and reread anyway when the file has moved on under a tab
holding no unsaved edits.
scene_tree takes a root as an entity id or a name, and a depth counting
generations below it: 0 is the node alone, 1 adds its children, and no
depth reports the whole subtree.
scene_tree(root: "Terrain", depth: 1)
After anything that takes time (opening a scene, a navmesh bake, a project
build), wait(until_idle: true) holds until nothing is running, including models
an opened scene is still loading.
Aim the camera before screenshotting a 3D scene. view.frame_all and
view.frame_selected keep the camera’s current orientation, so a level camera
frames a terrain edge-on.
call_operator(id: "view.look_at",
params: { eye_x: 120, eye_y: 90, eye_z: 120,
target_x: 0, target_y: 0, target_z: 0 })
call_operator(id: "view.orbit", params: { yaw: 135, pitch: 40, distance: 200 })
view.look_attakes an eye and a target in world metres and switches the viewport to perspective.view.orbitturns around the focus point the lastlook_ator orbit set, taking a compassyaw, apitchabove the ground in degrees, and adistancein metres.view.dollymoves along the sightline.
select frames what it selected when asked, and screenshot aims before it
captures:
select(names: ["Village"], frame: true)
screenshot(look_at: { eye: [120, 90, 120], target: [0, 0, 0] })
Playing the game from a client
pie.play builds the project’s game binary and launches it. In the default
embedded mode the game streams into the editor’s Game panel, so a window capture
shows the running game.
The launch is a cargo build then a process, so status reports progress under
pie – building, running or stopped – and wait holds for either end:
call_operator(id: "pie.play")
wait(until: "pie_running")
screenshot(kind: "window")
call_operator(id: "pie.stop")
wait(until: "pie_stopped")
A paused game counts as running.
Packing repeated groups
prefab.pack writes a group out as a prefab file and leaves an instance standing
where the group stood. prefab.pack_matching does that once, then replaces every
other matching top-level group with an instance of the same file, each keeping
its own placement:
call_operator(id: "prefab.pack_matching",
params: { entity: 4294967301, path: "prefabs/steading.bsn" })
match is structural by default, comparing whole subtrees; prefix compares
names against prefix instead. path is relative to the project’s assets
directory and cannot leave it, and an existing file needs overwrite: true. The
number of groups turned into instances comes back in the call’s reports.
When a call did not do what you asked
An operator reports a parameter it could not use in the call result’s
warnings. For input.pointer, button is primary, secondary or middle
(left and right are aliases) and space is window or canvas; anything
else is refused with a warning rather than treated as the default. Warnings
belong to the call that produced them.
Operators that need a pointer
Modal operators hold a gesture open across frames and end when the mouse button
comes up. A caller with no pointer cannot drive one, so each has a parametric
equivalent – terrain.sculpt.stamp for the sculpt brush,
entity.set_transform for a gizmo drag, selection.select for a rubber band.
The pairs are listed in tests/operators/remote_coverage.rs, and a new modal
operator fails that test until its remote equivalent is named.
Calling one anyway returns running and leaves that operator holding the editor,
which then refuses every later modal call. status reports it under modal,
cancel ends it, and batch cancels one it started. wait(until_idle: true)
does not wait on a modal – it answers and names it instead.
Open challenges
Work that is unfinished, partly done, or hard. Nothing here is shipped. If you want to take a swing at any of it, please file an issue first so the approach can be talked through.
Windows dylib hardening
The editor loads extension code as libraries 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.
Extensions are cdylibs that export only their entry points, so the
ceiling applies to the SDK’s own libraries.
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.
- 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.
Asset processing pipeline
Asset processing happens only at editor runtime. To pre-process textures or bake meshes for a CI build, you have to start the editor headlessly.
The second shape below is half built: 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
jdwith aprocesssubcommand alongsidebuild. 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
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, because the save filter is at
entity granularity. An 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 are harder 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. 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 this can be done without breaking BrushFaceEntity
queries that already work.
A material override on a part of a model
The Material row on the inspector writes a handle onto the
entity it is showing. When that entity is a part of a loaded
glTF, the scene document has no node for it: the model file is
the whole of what the document says about the instance, and the
parts under it are spawned by the loader. So the row falls back
to wear_until_reloaded, which swaps the handle on the live
entity, mints an undo entry, and warns that the pick is kept
only until the model is loaded again. Reopening the scene brings
back the material the glTF names.
What the document is missing is a way to address a part. The
shape that fits the rest of the format is a path of node names
from the model root – the Name each glTF node is spawned
with, joined by / – carried by a component on the entity that
holds GltfSource, so one entity’s overrides sit in one patch
under the node the document already has:
jackdaw_scene_types::types::GltfSource { path: "models/town.glb", scene_index: 0 }
jackdaw_scene_types::types::ModelPartMaterials {
parts: map[("Roof/Tiles", "materials/slate.bsn")],
}
Four pieces make it work. A component of that shape, registered
and reflected like the rest of jackdaw_scene_types. A walk
that turns an entity under a model instance into its path of
names, and the reverse walk that finds the entity a path names.
An observer on the spawned instance that re-applies every
override once the model’s entities are there, which is the same
moment WorldAssetRoot is derived, and again whenever the file
changes on disk. And a writer on the Material row that, for an
entity with no node of its own, walks up to the model root and
writes the entry rather than wearing the handle.
The parts that need care are the ones that decide whether it is
worth having. A name is not unique in a glTF, so two nodes
called Tiles under different parents are told apart only by
the path above them, and a model with two identical siblings is
not addressable at all – the override has to refuse that case
rather than pick one. A model that is re-exported with renamed
nodes leaves entries naming nothing, which wants the same
“names nothing the project holds” reporting a missing asset
path gets rather than a silent drop. Undo has to take the
document entry and the live handle together. And the row has to
read the override back, so what it shows after a reload is the
path the document holds rather than the material the glTF
names.
Where to dig in: the name-path walk and its refusal on ambiguous siblings, which is where the design either holds or needs a stable per-node id from the loader instead.
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"
# The asset folder, when the game's AssetPlugin reads from somewhere
# other than `assets`.
# assets_dir = "content"
[jackdaw]
version = "0.20.1"
bevy = "0.20"
channel = "stable"
[modules]
physics = true
navmesh = false
multiplayer = false
[extensions]
"acme.dialogue" = true
[[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 BevyPlugintype. Recorded by import/setup and checked byjd doctor; Play launches your cargo binary, which must add the plugin itself inmain.rs.assets_dir: the asset folder, relative to the project root. Defaults toassets. The editor reads, writes and serves assets from this folder; set it to the same folder your game’sAssetPlugin { file_path, .. }names, since the editor cannot read that setting out of your code. The path must stay inside the project; an absolute path or one starting with..is ignored. A change takes effect the next time the editor starts.
[jackdaw] records the editor the project was set up with. jd open
opens a stable project in the newest installed patch of its
version’s line, so 0.20.1 opens in 0.20.3 once that is installed.
A bleeding-edge project also records build (the nightly, such as
nightly-2026-10-09) and bevy_rev, and opens only in that nightly.
A missing channel means stable.
[modules] records the optional modules the project uses. Each
module pairs editor tooling with what the game needs for it:
| Module | Game side | Requires |
|---|---|---|
physics | avian3d and its PhysicsPlugins, jackdaw_avian_integration and its AvianColliderBridgePlugin | |
navmesh | jackdaw_runtime feature navmesh | |
multiplayer | jackdaw_multiplayer and its types plugin |
Physics is not available for Bevy 0.20 projects until avian releases
for it; the dialogs show it greyed out with that reason, and jd new
refuses it.
Physics and Navmesh bring their editor tooling as extensions
(jackdaw.physics and jackdaw.navmesh) rather than building it into
the editor.
Pick them in the New Project and import dialogs, with
jd new --modules navmesh,multiplayer (or --no-modules) and
jd import --modules ..., and later with File > Modules in the
editor. Changing them there edits [modules] and the game’s
Cargo.toml together; turning a module off removes its runtime
features but leaves its crates, which your code may still use. A
project with no [modules] table is read from the features its
jackdaw_runtime dependency already enables. New games start with
physics only.
[extensions] turns editor extensions on or off for this project, by
extension id, over the global list every project starts from. The editor
applies it when the project opens; extensions it does not name follow
the global list. Edit it from the launcher’s Extensions page, where the
global defaults are edited too.
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 toPlay.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, andeditor-previewis 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 edits the project’s own Cargo.toml only when you change
its modules, and never touches its Cargo.lock, target/, or
toolchain.
Command line
jackdaw is exclusively the GUI. jd is the sole public command:
jd new <name> [--extension] [--modules <list> | --no-modules]jd import [path] [--plugin <Type>] [--modules <list>] [--apply]jd open [path]jd editor <list|install|remove|default|locate|clean>jd self updatejd build [--project <path>]jd run [--project <path>]jd setupjd doctorjd 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:
JACKDAW_SDK_DIR, if set. Usually an installed layout: ansdk/manifest.txtwith the rustc wrapper,Cargo.lock, andtoolchain.txtbeside it. A bootstrap cache directory or a jackdaw checkout is accepted too, so pointing it at any of the three works.- 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. - The same installed layout next to the running executable. This is what a downloaded bundle uses, with no env var.
- The bootstrap cache at
jackdaw/sdk/<version>-<toolchain>/in the local data directory:~/.local/shareon Linux (or$XDG_DATA_HOMEwhen that is set to an absolute path),~/Library/Application Supporton macOS, and%LOCALAPPDATA%on Windows. Written byjd setupor the editor’s on-demand SDK build, with the editor’s build log insetup.log. 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 SDK fails the report only for
an extension project, since games never build against 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; the game brings its own networking backend.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.
A release editor on Windows runs without a console window and writes its log,
panics included, to %LOCALAPPDATA%\jackdaw\logs\editor.log. The previous
run’s log is kept beside it as editor.previous.log.
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_tabindexes into it and is clamped on load.layout: persisted dock layout, parsed asjackdaw_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 fallback toscene.bsnin the asset folder 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(jackdaw_editor::JackdawEditorPlugins::default())
.run();
}
Notes:
DylibLoaderPluginis 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 a stable release,
and CI and the release bundles use the same one. The SDK and
extensions build with the exact rustc that built the editor:
extension builds and the SDK have to share one compiler for the
shared type graph to line up, so setup installs that release
through rustup rather than using whatever is selected. A bundle
records its release in toolchain.txt.
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.