bevyengine / bevyengine/bevy

Broken entity hierarchy when projecting sub-scene through scene component with names

Open
#25,407 1 comment 1 reaction 0 assignees View on GitHub
A-Scenes C-Bug D-Modest S-Ready-For-Implementation
Dominant language
Rust
Stars
48.2k
Forks
4.8k
Avg merge
3d 16h
Merged PRs (30d)
171

Description

## Bevy version and features

`0.19.1`

(Testing with `default-features = false, features = ["bevy_log", "scene", "debug"]` for simplicity but there's no difference in behavior with default features.)

## What you did

Create a `bsn!` scene with a name on an entity that uses a scene component with projected children that also have names.

All three of those conditions are required to produce the bug:

- The parent entity has a name
- The child entity has a name
- The child entity is "projected" through a scene component
- i.e. the scene component on the parent provides `Children` using the scene list given as a prop

Example:

```plaintext
bsn! {
#Parent
@Container {
@children: bsn_list! [ #Child ]
}
}
```

Where `Container` is defined as:

```plaintext
#[derive(SceneComponent, Clone, Default)]
#[scene(ContainerProps)]
struct Container;

impl Container {
fn scene(props: ContainerProps) -> impl Scene {
bsn! {
Children [ {props.children} ]
}
}
}

struct ContainerProps {
children: Box,
}

impl Default for ContainerProps {
fn default() -> Self {
Self { children: Box::new(bsn_list![]) }
}
}
```

This use of BSN can be found at the bottom of the [syntax example](https://docs.rs/bevy_scene/latest/bevy_scene/macro.bsn.html#syntax-example) for the `bsn!` macro.

## What went wrong

The expected entity hierarchy of such a scene is:

```plaintext
#Parent
└── #Child
```

However, spawning this scene results in a broken relationship and misplaced components. The entities are not related at all: one is not a child of the other. Running the app also produces these logs which explains what happened to the parent-child relationship:

```plaintext
2026-08-14T00:00:51.844204Z WARN bevy_ecs::relationship: The bevy_ecs::hierarchy::ChildOf(30v0) relationship on entity 30v0 points to itself. The invalid bevy_ecs::hierarchy::ChildOf relationship has been removed.
If this is intended behavior self-referential relations can be enabled with the allow_self_referential attribute: #[relationship(allow_self_referential)]
```

And if we give each entity a marker component in the `bsn!` to help identify them, then, when the scene spawns, one of the two entities ends up with both markers and the child's name, and the other is not even spawned.

Code

bsn! {

P
#Parent
@Container {
@children: bsn_list! [ C #Child ]
}
}

## Additional information

I attempted to reduce the example even further, but removing any one of the three previously-mentioned conditions results in the expected parent-child relationship and correctly assigned components.

If no name is given to the parent, then there is no bug:

```plaintext
bsn! {
@Container {
@children: bsn_list! [ #Child ]
}
}
```

If no name is given to the child, then there is no bug:

```plaintext
bsn! {
#Parent
@Container {
@children: bsn_list! [ () ]
}
}
```

If the child is directly related to the parent with `Children`, and not through the scene component, then there is no bug:

```plaintext
bsn! {
#Parent
@Container
Children [ #Child ]
}
```

While iterating on the MRE I wrote the following tests:

Full Code

//! ```cargo

//! [dependencies]
//! bevy = { version = "0.19.1", default-features = false, features = ["bevy_log", "scene", "debug"] }
//! ```

use bevy::prelude::*;

/// A scene component that simply spawns an empty entity with the given scene list as its
/// `Children`.
///
/// A scene declared as:
///
/// ```bsn
/// @Container {
/// @children: bsn_list![ #Child ]
/// }
/// ```
///
/// creates a hierarchy:
///
/// ```plaintext
/// (empty)
/// └── #Child
/// ```
#[derive(SceneComponent, Clone, Default)]
#[scene(ContainerProps)]
pub struct Container;

impl Container {
fn scene(props: ContainerProps) -> impl Scene {
bsn! {
Children [
{ props.children }
]
}
}
}

pub struct ContainerProps {
pub children: Box<dyn SceneList>,
}

impl Default for ContainerProps {
fn default() -> Self {
Self {
children: Box::new(bsn_list![]),
}
}
}

#[derive(Component, Clone, Default)]
struct A;

#[derive(Component, Clone, Default)]
struct B;

fn test_app() -> App {
let mut app = App::new();
app.add_plugins((
bevy::log::LogPlugin::default(),
AssetPlugin::default(),
bevy::scene::ScenePlugin,
));
app
}

/// Checks for expected entity states:
///
/// - A: `expected_name_a ChildOf( ) Children[B]`
/// - B: `Name("B") ChildOf(A) Children[ ]`
#[track_caller]
fn check(world: &mut World, expected_name_a: Option<&str>, expected_name_b: Option<&str>) {
let a = {
let maybe_a = world.query_filtered::<Entity, With<A>>().single(world);
assert!(maybe_a.is_ok(), "no entity A: {maybe_a:?}");
maybe_a.unwrap()
};

let b = {
let maybe_b = world.query_filtered::<Entity, With<B>>().single(world);
assert!(maybe_b.is_ok(), "no entity B: {maybe_b:?}");
maybe_b.unwrap()
};

assert_ne!(a, b, "A and B are the same entity");

type TestComponents<'a> = (Option<&'a Name>, Option<&'a ChildOf>, Option<&'a Children>);
fn get_components(world: &mut World, entity: Entity) -> TestComponents<'_> {
world.query::<TestComponents>().get(world, entity).unwrap()
}

// Check Entity: A ---

{
let (maybe_name, maybe_child_of, maybe_children) = get_components(world, a);

// A has expected Name
match expected_name_a {
Some(expected_name) => {
assert!(maybe_name.is_some(), "entity A has no Name");
let name = maybe_name.unwrap().as_str();
assert_eq!(name, expected_name, "entity A has unexpected name: {name}");
}
None => {
assert!(
maybe_name.is_none(),
"entity A has unexpected name: {:?}",
maybe_name.map(Name::as_str)
);
}
}

// A is not a ChildOf any other entity
assert!(
maybe_child_of.is_none(),
"entity A has unexpected ChildOf: {maybe_child_of:?}"
);

// A has child B
assert!(maybe_children.is_some(), "entity A has no children");
let maybe_child = maybe_children.unwrap().iter().next();
assert!(maybe_child.is_some(), "entity A has empty children");
let child = maybe_child.unwrap();
assert_ne!(child, a, "entity A is child of itself");
}

// Check Entity: B ---

{
let (maybe_name, maybe_child_of, maybe_children) = get_components(world, b);

// B has expected Name
match expected_name_b {
Some(expected_name) => {
assert!(maybe_name.is_some(), "entity B has no Name");
let name = maybe_name.unwrap().as_str();
assert_eq!(name, expected_name, "entity B has unexpected name: {name}");
}
None => {
assert!(
maybe_name.is_none(),
"entity B has unexpected name: {:?}",
maybe_name.map(Name::as_str)
);
}
}

// B is a ChildOf A
assert!(maybe_child_of.is_some(), "entity B has no ChildOf");
let child_of = maybe_child_of.unwrap();
assert_eq!(
child_of.0, a,
"entity B has unexpected ChildOf: {child_of:?}"
);

// B has no children
assert!(
maybe_children.is_none(),
"entity B has unexpected children: {maybe_children:?}"
);
}
}

/// Expected:
///
/// - A: `Name("A") ChildOf( ) Children[B]`
/// - B: `Name("B") ChildOf(A) Children[ ]`
///
/// Actual:
///
/// - A,B: `Name("B") ChildOf( ) Children[ ]`
/// - (empty)
#[test]
fn names_on_both() {
let mut app = test_app();
let world = app.world_mut();
world
.spawn_scene(bsn! {
A
#A
@Container {
@children: bsn_list![ #B B ]
}
})
.unwrap();
check(world, Some("A"), Some("B"));
}

/// Expected:
///
/// - A: `Name("A") ChildOf( ) Children[B]`
/// - B: `Name( ) ChildOf(A) Children[ ]`
///
/// Actual: correct
#[test]
fn name_only_on_scene_component() {
let mut app = test_app();
let world = app.world_mut();
world
.spawn_scene(bsn! {
A
#A
@Container {
@children: bsn_list![ B ]
}
})
.unwrap();
check(world, Some("A"), None);
}

/// Expected:
///
/// - A: `Name( ) ChildOf( ) Children[B]`
/// - B: `Name("B") ChildOf(A) Children[ ]`
///
/// Actual: correct
#[test]
fn name_only_on_child() {
let mut app = test_app();
let world = app.world_mut();
world
.spawn_scene(bsn! {
A
@Container {
@children: bsn_list![ #B B ]
}
})
.unwrap();
check(world, None, Some("B"));
}

/// Expected:
///
/// - A: `Name( ) ChildOf( ) Children[B]`
/// - B: `Name("B") ChildOf(A) Children[ ]`
///
/// Actual: correct
#[test]
fn not_projected() {
let mut app = test_app();
let world = app.world_mut();
world
.spawn_scene(bsn! {
#A
A
@Container
Children [
#B B
]
})
.unwrap();
check(world, Some("A"), Some("B"));
}

Output

running 4 tests

2026-08-14T03:28:46.325786Z ERROR bevy_log: Could not set global logger and tracing subscriber as they are already set. Consider disabling LogPlugin.
2026-08-14T03:28:46.325787Z ERROR bevy_log: Could not set global logger as it is already set. Consider disabling LogPlugin.
2026-08-14T03:28:46.325789Z ERROR bevy_log: Could not set global tracing subscriber as it is already set. Consider disabling LogPlugin.
2026-08-14T03:28:46.325789Z ERROR bevy_log: Could not set global logger and tracing subscriber as they are already set. Consider disabling LogPlugin.
2026-08-14T03:28:46.327445Z WARN bevy_ecs::relationship: The bevy_ecs::hierarchy::ChildOf(30v0) relationship on entity 30v0 points to itself. The invalid bevy_ecs::hierarchy::ChildOf relationship has been removed.
If this is intended behavior self-referential relations can be enabled with the allow_self_referential attribute: #[relationship(allow_self_referential)]
test not_projected ... ok
test name_only_on_child ... ok
test name_only_on_scene_component ... ok
test names_on_both ... FAILED

failures:

---- names_on_both stdout ----

thread 'names_on_both' (5263630) panicked at tests/primary.rs:140:5:
assertion `left != right` failed: A and B are the same entity
left: 30v0
right: 30v0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
names_on_both

test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

---

*EDIT: improve wording in description & provide full scene component definition*

Contributor guide

Open the contributing guide

Research direction

Start by running the four reproduced tests, especially names_on_both in tests/primary.rs, and trace how bsn!, SceneComponent projection, and spawn_scene assign Name and hierarchy components. The fix is complete when the parent and child remain distinct, the parent owns the child, both names and markers are preserved, and the other three tests still pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
game-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.