Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Custom components

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

Minimum

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

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

That’s it. The editor builds your project’s game binary in the background when the project opens and extracts its type schema; once that finishes, open the inspector on an entity, click + Add Component, type PlayerSpawn. It shows up.

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

A few things make this work without ceremony:

  • Bevy’s reflect_auto_register registers the type when the schema extractor runs your game binary, so you don’t need app.register_type::<PlayerSpawn>() and there is no jackdaw-specific registration code anywhere. A type in a dependency crate that your library never references can be stripped by the linker before registration runs; register it explicitly if it never shows up.
  • jackdaw_runtime enables bevy’s reflect_documentation feature, so doc comments on the type become picker tooltips.
  • Jackdaw can construct a default-valued instance from primitive field defaults, so you don’t strictly need Default. Adding it is just nicer.

Categories and tooltip overrides

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

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

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

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

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 @EditorHidden somewhere (intentional or pasted from a template).
  • The project hasn’t been rebuilt since you added the type. Run Rebuild Project or jd build.

Doc comment doesn’t show as tooltip. Tooltips need bevy’s reflect_documentation feature. jackdaw_runtime turns it on; if you patch or vendor your own bevy, make sure reflect_documentation is in its feature list.

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

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