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.