Writing the World's Worst ECS

I'm writing a game engine in Rust.

*gasp*

I know! Never been done before.

Overview of the systems

But I'm writing a game engine in Rust, just because I thought it would be fun and I am tired of C++. This hasn't been new; I've stopped writing C++ just due to a general tiredness from the language. I kept running into language-specific quirks, and the difficulty adding a library to a C++ project made me want to invent more here. With Rust, I can add libraries easily and with Cargo I don't have to futz with makefiles.

But that's neither here nor there, because instead of using mature ecosystem components, like anything Bevy related, I have decided to write raw OpenGL with an SDL2 window. No Winit here!

But why? Well, because I think they suck. They are too opinionated and restrict what I want to do. Bevy forces you into their way of subscribing systems and whatnot with their ECS, and Winit's default path is having you register a callback to refresh the window. Yuck!

So instead, I've decided to go manual. And a part of this manual gearbox is a custom ECS. Now, I've never done a "proper" ECS. My component systems were usually just pointers allocated on the heap owned by the entity. It worked, I made some games with it, but Rust forces me into an ECS due to how it handles ownership. It's just the path of least resistance.

Now how does my ECS work? Terribly!

Roughly; all entities are stored in pairs: their handle, and a bitset representing the components they own. Nothing atypical yet.

Components are registered with a trait that forces you to define their tag. This is just any object which can be turned into a u32. Likewise, they have to define a function with returns their local-ID. This ID can be anything, but I use it to determine their index within the container that stores them. Combined, these two elements compose the entity's unique ID: the lower 32 bits are the local-ID and the upper 32 bits is the tag. Simple!

Something I did steal from Bevy is their query system. Mine is not as fancy, but it works well to define when I want to capture components. This is a struct created by a builder pattern which lets you define which components to query. How do we store which component to query? Well, with their tag of course!

Now, this all works fairly well together. It lets us pretty easily define components, entities, and even a vague way to query for components and entities. So why is my ECS the worst in the world?

The reason my ECS is the worst in the world

I want my data to be pure, and I want my data to be contiguous. Also: I want my data to be parallelisable. Does the game I'm writing need to be? Of course not! But future games may. So I must over-engineer now!

A system works by defining the components it will operate on with a query. This works as a contract, so the system can guarantee the entities it gathers will only have those components which it asks for. Still simple.

But even though we know the component we are working on will be only mutable by one source, Rust will be pissed we have many references around. I could use a RefCell, but I also want my data to be operated on locally so I can benefit from cache hits. So I reached into my unsafe toolbox and wrote a container which stores any-sized components contigiously into memory. That's right! I wrote a one-time-write bump allocator.

Whenever a system ticks, we will go through every component store, and get the components which are going to be relevant for the system. This is returned with an entity:component pairing. Once we get every component, we then, for every entity, allocate one of these bump allocators and store the entity's components into it. We group all of the allocators together, ship it off to the system, and then we are happy.

What's that? You want state that changes across frames? Why aren't you picky.

Okay so after we tick a system, we then have to go back to each component store and update each component's original source. Now, I justified this to myself because I convinced myself I could eventually write a graph-based executor which would let systems run in parallel, mutating data that would never have a chance to touch each other. Do I have this right now? No. Do I need this? ... no. Why go this complex?

:)

Does this suck?

Well it certainly fucking sucks to write.

let query = controller::PlayerControllerSystem::query();
let relevant_entities = entities.entities_with_components(query);

let mut transforms = transforms.components_matching_entities(&relevant_entities);
let mut colliders = colliders.components_matching_entities(&relevant_entities);
let mut particles = particles.components_matching_entities(&relevant_entities);
let mut player_controllers = player_controllers.components_matching_entities(&relevant_entities);
let mut cameras = cameras.components_matching_entities(&relevant_entities);

transforms.sort_unstable_by(|l, r| l.0.cmp(&r.0));
colliders.sort_unstable_by(|l, r| l.0.cmp(&r.0));
particles.sort_unstable_by(|l, r| l.0.cmp(&r.0));
player_controllers.sort_unstable_by(|l, r| l.0.cmp(&r.0));
cameras.sort_unstable_by(|l, r| l.0.cmp(&r.0));

let mut groups = Vec::new();
for (idx, entity) in relevant_entities.into_iter().enumerate() {
    let mut component_group = component::Group::<64>::new(entity);
    if !transforms.is_empty() {
        component_group.assign(transforms[idx].1, Mutability::Constant);
    }
    if !colliders.is_empty() {
        component_group.assign(colliders[idx].1, Mutability::Constant);
    }
    if !particles.is_empty() {
        component_group.assign(particles[idx].1, Mutability::Constant);
    }
    if !player_controllers.is_empty() {
        component_group.assign(player_controllers[idx].1, Mutability::Constant);
    }
    if !cameras.is_empty() {
        component_group.assign(cameras[idx].1, Mutability::Constant);
    }
    groups.push(component_group)
}
controller_system.tick(&grid, dt, &mut groups);

        

And this is ONE system tick!

... maybe I should have kept it simple. stupid.

Does it work

I have no idea! At all. It may work it may not. I bet it will be very slow, but I think it could potentially be performant. I just need to use it a bit, see if I can abstract this hellscape whatsoever, and then I will be back.

That's right! I wrote this without being done first. Oh well, it's another article for another time