A lot of Rust game engines rely on deferring actions or on interior mutability, and one of the main reasons is:
trait GameComponent {
fn tick(&mut self, world: &mut World) {}
}
This can't compile in a useful way. the component lives inside the world, so self and world are overlapping mutable borrows. The borrow checker is right to complain.
Enums sidestep this since the match hands you the concrete type, so you control borrow scopes yourself and can re-look-up after touching the world. But an engine library can't use enums. Users of a game engine library have to define their own behaviors, and enums are a closed set. So you eventually need trait objects, and then you're back at the borrow conflict, since you have to have a reference to the trait object to make it work, which is a part of World, which disqualifies &mut World access since ur already borrowing the trait object that is a part of the world.
The only way in stable rust to avoid this issue is defering game actions till later, or using ECS
The idea: the receiver is a key, not a borrow
I built a slotmap variant where keys are typed, upcastable, and carry vtable metadata, so you can do dynamic dispatch through the key while &mut World stays free:
rust
#![feature(ptr_metadata, coerce_unsized, unsize, dispatch_from_dyn,
arbitrary_self_types, arbitrary_self_types_pointers)]
use cast_slotmap::{BoxCastMap, TypeTaggedBox, CastKey, DefaultKey};
use std::any::Any;
struct Dog { name: String }
let mut map: BoxCastMap<DefaultKey, dyn Any> = BoxCastMap::new();
// Insert a concrete type into a `dyn Any` map; the key comes back typed.
let dog_key: CastKey<Dog> = map.insert_sized(TypeTaggedBox::new(Dog { name: "Rex".into() }));
assert_eq!(map.get(dog_key).unwrap().name, "Rex");
rust
trait GameComponent: AnyHaver {
// `self` is a *key*, not a borrow, so `&mut World` is free to coexist.
fn tick(self: DynKey<'_, Self>, world: &mut World) {}
}
struct World {
components: BoxCastMap<DefaultKey, dyn GameComponent>,
}
impl GameComponent for Dog {
fn tick(self: DynKey<'_, Self>, world: &mut World) {
let me: &mut Dog = world.components.get_mut(self.key()).unwrap();
me.name.push('!');
// borrow of `me` ends whenever you choose; then touch the rest
// of the world, look up other components by their keys, etc.
}
}
fn tick_all(world: &mut World) {
// snapshot the keys, releasing the map borrow
let keys: Vec<CastKey<dyn GameComponent>> = world.components.keys().collect();
for key in keys {
key.as_dyn().tick(world); // virtual call through the vtable cached in the key
}
}
Result: no deferral, no RefCell, no reentrancy panics, and World stays Send + Sync.
Status and caveats
- This is a feasibility proof, not a "use this today" crate. It needs six nightly features (
ptr_metadata,coerce_unsized,unsize,dispatch_from_dyn,arbitrary_self_types,arbitrary_self_types_pointers). The point is that Rust has a potential to express this pattern - Ergonomics aren't ideal yet: you call
as_dyn()before dispatch becauseDynKeyhas to borrow theCastKey, due toDispatchFromDynrequiring pointer sized implementirs. Though this ergonomic gap can be resolved with more compiler support - Anticipating some replies:
RefCelltrades the compile error for reentrancy panics, still can't insert while borrowing world, and disqualifies your world from usingSend+Sync. ECS avoids this problem differently and is out of scope. this targets non-ECS designs.
Repo: GitHub - izagawd/cast_slotmap · GitHub
The library is generic along several axes, not just the example path, which is why there is a good amount of code:
- Stored pointer: the
CastMapstore anyTypeTaggedPtr<P>where P can be one ofBox,Rc,Arc,&T,&mut TTypeTaggedBoxis just an alias forTypeTaggedPtr<Box<T>> - Backing storage:
BoxCastMap<K, T>is just an alias:CastMapG<SlotMap<K, TypeTaggedBox<T>>>. The real type isCastMapG<M: SlotMapTrait>. You can swap it to use theDenseSlotMapvariant
I'd appreciate feedback on the design itself. (Disclosure: Claude assisted with the implementation, so the docs may be verbose.)