bevy_entity_ptr
Ergonomic smart-pointer-like access to Bevy ECS entities with immutable-only semantics.
Overview
This crate provides two complementary approaches for accessing entity data in Bevy:
| Type |
Safety |
Ergonomics |
Use When |
EntityHandle |
✅ Fully safe |
Explicit world param |
Store in components |
BoundEntity<'w> |
✅ Fully safe |
Scoped lifetime |
Simple access, compiler-checked |
EntityPtr |
✅ Safe API* |
No lifetime params |
Tree/graph traversal, recursion |
*One internal unsafe hidden by WorldExt extension trait
Recommendation: Start with BoundEntity<'w>. Use EntityPtr when lifetime
annotations become cumbersome for complex traversal.
Design Principles
- Immutable only - No
get_mut variants (functional programming style)
- Safe by default -
WorldExt trait hides the internal unsafe, users never write unsafe blocks
- Graceful stale handling - Despawned entities return
None, not undefined behavior
- Zero-cost where possible -
#[repr(transparent)], #[inline], const fn
Installation
Add to your Cargo.toml:
[dependencies]
bevy_entity_ptr = "0.2"
Quick Start
Safe Approach: EntityHandle + BoundEntity
Use this when you want fully safe code with explicit world parameters:
use bevy_ecs::prelude::*;
use bevy_entity_ptr::{EntityHandle, BoundEntity};
#[derive(Component)]
struct Parent(EntityHandle);
#[derive(Component)]
struct Name(String);
fn find_parent_name(entity: Entity, world: &World) -> Option<String> {
let handle = EntityHandle::new(entity);
let bound = handle.bind(world);
let parent = bound.follow::<Parent, _>(|p| p.0)?;
parent.get::<Name>().map(|n| n.0.clone())
}
Ergonomic Approach: WorldExt + EntityPtr
Use this when you want fluent traversal without passing &World everywhere.
The WorldExt extension trait hides the internal unsafe, so you never need to write unsafe blocks.
use bevy_ecs::prelude::*;
use bevy_entity_ptr::{WorldExt, EntityPtr, EntityHandle};
#[derive(Component)]
struct Parent(EntityHandle);
#[derive(Component)]
struct Health(i32);
#[derive(Component)]
struct TreeChildren(Vec<EntityHandle>);
fn sum_tree_health(node: EntityPtr) -> i32 {
let my_health = node.get::<Health>().map(|h| h.0).unwrap_or(0);
let children_health: i32 = node
.get::<TreeChildren>()
.map(|c| {
c.0.iter()
.map(|h| sum_tree_health(node.follow_handle(*h)))
.sum()
})
.unwrap_or(0);
my_health + children_health
}
fn find_root(node: EntityPtr) -> EntityPtr {
match node.follow::<Parent, _>(|p| p.0) {
Some(parent) => find_root(parent),
None => node,
}
}
fn health_system(world: &World) {
for entity in world.iter_entities() {
let ptr = world.entity_ptr(entity.id());
let total = sum_tree_health(ptr);
println!("Subtree health: {}", total);
}
}
Mixed Usage
Store handles in components, use smart pointers for traversal:
use bevy_ecs::prelude::*;
use bevy_entity_ptr::{EntityHandle, WorldExt, EntityPtr};
#[derive(Component)]
struct Inventory {
items: Vec<EntityHandle>,
}
#[derive(Component)]
struct Weight(f32);
fn total_inventory_weight(player: EntityPtr) -> f32 {
player
.get::<Inventory>()
.map(|inv| {
inv.items
.iter()
.filter_map(|h| player.follow_handle(*h).get::<Weight>())
.map(|w| w.0)
.sum()
})
.unwrap_or(0.0)
}
fn inventory_system(world: &World, player_entity: Entity) {
let player = world.entity_ptr(player_entity);
let weight = total_inventory_weight(player);
println!("Total inventory weight: {}", weight);
}
Navigation Traits (Optional)
Enable the nav-traits feature for parent/child navigation helpers:
[dependencies]
bevy_entity_ptr = { version = "0.2", features = ["nav-traits"] }
use bevy_ecs::prelude::*;
use bevy_entity_ptr::{WorldExt, HasParent, HasChildren};
#[derive(Component)]
struct ParentRef(Option<Entity>);
impl HasParent for ParentRef {
fn parent_entity(&self) -> Option<Entity> {
self.0
}
}
#[derive(Component)]
struct ChildRefs(Vec<Entity>);
impl HasChildren for ChildRefs {
fn children_entities(&self) -> &[Entity] {
&self.0
}
}
fn navigate_tree(world: &World, entity: Entity) {
let ptr = world.entity_ptr(entity);
if let Some(parent) = ptr.nav().parent::<ParentRef>() {
println!("Has parent: {:?}", parent.entity());
}
let children = ptr.nav_many().children::<ChildRefs>();
println!("Has {} children", children.len());
}
Thread Safety
| Type |
Send |
Sync |
Notes |
EntityHandle |
Yes |
Yes |
Safe to store in components |
BoundEntity<'w> |
No |
No |
Borrows &World |
WorldRef |
No |
No |
System-scoped only |
EntityPtr |
No |
No |
System-scoped only |
Using EntityPtr in Collections
EntityPtr implements Eq and Hash, allowing use in HashSet and HashMap:
use std::collections::HashSet;
use bevy_ecs::prelude::*;
use bevy_entity_ptr::WorldExt;
fn find_unique_targets(world: &World, entities: &[Entity]) -> HashSet<Entity> {
let mut seen = HashSet::new();
for &entity in entities {
let ptr = world.entity_ptr(entity);
if seen.insert(ptr) {
}
}
seen.into_iter().map(|ptr| ptr.entity()).collect()
}
Note: EntityPtr comparison uses entity ID only, assuming same-world context (the typical usage pattern).
Multi-Threaded Usage Example
Multiple read-only systems can use bevy_entity_ptr concurrently. Bevy's scheduler runs them in parallel when all systems only read:
use bevy_ecs::prelude::*;
use bevy_entity_ptr::{EntityHandle, EntityPtr, WorldExt};
#[derive(Component)]
struct Health(i32);
#[derive(Component)]
struct Armor(i32);
#[derive(Component)]
struct Children(Vec<EntityHandle>);
#[derive(Component)]
struct RootMarker;
fn sum_health(node: EntityPtr) -> i32 {
let my_health = node.get::<Health>().map(|h| h.0).unwrap_or(0);
let children_health: i32 = node
.get::<Children>()
.map(|c| c.0.iter().map(|h| sum_health(node.follow_handle(*h))).sum())
.unwrap_or(0);
my_health + children_health
}
fn sum_armor(node: EntityPtr) -> i32 {
let my_armor = node.get::<Armor>().map(|a| a.0).unwrap_or(0);
let children_armor: i32 = node
.get::<Children>()
.map(|c| c.0.iter().map(|h| sum_armor(node.follow_handle(*h))).sum())
.unwrap_or(0);
my_armor + children_armor
}
fn compute_health_system(world: &World, query: Query<Entity, With<RootMarker>>) {
for entity in &query {
let total = sum_health(world.entity_ptr(entity));
println!("Total health: {}", total);
}
}
fn compute_armor_system(world: &World, query: Query<Entity, With<RootMarker>>) {
for entity in &query {
let total = sum_armor(world.entity_ptr(entity));
println!("Total armor: {}", total);
}
}
fn setup_app(app: &mut App) {
app.add_systems(Update, (compute_health_system, compute_armor_system));
}
Why this is safe:
WorldExt::entity_ptr() hides the internal unsafe - you never write unsafe blocks
EntityPtr is NOT Send/Sync - it cannot escape to other threads
- Bevy's scheduler detects that both systems only have
&World access and runs them in parallel
- All operations through
EntityPtr are read-only by design
See examples/concurrent_systems.rs for a complete runnable example.
Stale Reference Handling
Both approaches gracefully handle despawned entities:
use bevy_ecs::prelude::*;
use bevy_entity_ptr::EntityHandle;
#[derive(Component)]
struct Name(&'static str);
fn stale_handling_example(world: &mut World) {
let entity = world.spawn(Name("temporary")).id();
let handle = EntityHandle::new(entity);
assert!(handle.is_alive(world));
assert_eq!(handle.get::<Name>(world).unwrap().0, "temporary");
world.despawn(entity);
assert!(!handle.is_alive(world));
assert!(handle.get::<Name>(world).is_none());
}
Safety
For most users: The WorldExt extension trait (world.entity_ptr(entity)) hides all unsafe code. You never need to write unsafe blocks.
For advanced users: If you need direct access to WorldRef::new(), the caller must ensure:
- The
World outlives all EntityPtr instances created from the WorldRef
- The
World is NOT mutated while any EntityPtr exists
In Bevy systems, this is naturally satisfied: systems with &World access cannot mutate.
What This Crate Does NOT Support (By Design)
- Mutable access - Use Bevy's native APIs for mutations
- Despawning - Use
world.despawn() directly
- Component insertion/removal - Use Bevy's native APIs
- Cross-frame storage of
EntityPtr - Use EntityHandle or raw Entity for storage
License
MIT OR Apache-2.0