Rust 3D with Fyrox - Simple ECS

by

So far, I have been adding code without any structure, eventually leading to spaghetti code. It's terrible, and I stopped and rethought everything.

In-game development, there's a pattern called ECS. This pattern splits code into Resources and Systems. The system is a function which will perform logic; a resource is data available to this function.

A massive advantage of ECS is logic and data are separated, and there is one source of truth; all data are in one place. And logic is a small function which may depend on what happened before, but if an order is messed up, it's still possible everything will run correctly in the second run.

pub struct State {
    pub scene: Handle<Scene>, // A handle to a scene
    pub resources: HashMap<TypeId, Box<dyn Any>>,
}

impl State {
    pub fn new(scene: Handle<Scene>) -> Self {
        Self {
            scene,
            resources: HashMap::with_capacity(100),
        }
    }

    /// Insert new globally available resource
    /// There's no other way to store and use data in systems than insert them in resources
    pub fn insert_resource<Resource: 'static>(&mut self, resource: Resource) {
        self.resources
            .insert(resource.type_id(), Box::new(resource));
    }

    /// Retrieve an immutable reference to data from resources with the requested type.
    /// Data are stored using their type id and returned as a reference, so it panicked only when it wasn't inserted.
    pub fn resource<Resource: 'static>(&self) -> &Resource {
        self.resources
            .get(&TypeId::of::<Resource>())
            .and_then(|t| t.downcast_ref::<Resource>())
            .map(|t| t.borrow())
            .unwrap()
    }

    /// Retrieve and mutable reference to data from resources with the requested type.
    /// Data are stored using their type id and returned as a reference, so it panicked only when it wasn't inserted.
    pub fn resource_mut<Resource: 'static>(&mut self) -> &mut Resource {
        self.resources
            .get_mut(&TypeId::of::<Resource>())
            .and_then(|t| t.downcast_mut::<Resource>())
            .map(|t| t.borrow_mut())
            .unwrap()
    }
}

pub struct Game {
    state: Option<State>,
    systems: Vec<Box<dyn Fn(&mut State, &mut Engine, &Event<()>) + 'static>>,
}

impl Game {
    /// Add logic to the Game.
    /// In ECS, logic is split into small functions responsible for a very narrow thing
    pub fn system<S>(&mut self, system: S)
    where
        S: Fn(&mut State, &mut Engine, &Event<()>) + 'static,
    {
        self.systems.push(Box::new(system));
    }

    /// Insert new globally available resource
    /// There's no other way to store and use data in systems than insert them in resources.
    pub fn insert_resource<Resource: 'static>(&mut self, resource: Resource) {
        if let Some(state) = self.state.as_mut() {
            state.insert_resource(resource);
        }
    }
}

It is a massive chunk of code, so let us go through it slowly.

First, the State. It's the place we hold all the data required by the Game. It contains a scene handler, which allows us to load the current scene from the engine and resources, which are our data.

And now, the magic part. Resources are defined as Any, meaning we can hold anything we want here. Every type of Rust has a unique type id which can be obtained using resource.type_id().The key for this data is type id, and the value is pushed to the heap with a box (otherwise, Rust would not be happy not knowing the data size). Usually, I would be terrified and disgusted about using Any trait; Rust is about strong data typing. But here, it's justified, and also Rust is still capable of ensuring this is what we want. If we try downcast to the wrong type, Rust will give as None, and we must handle this situation.

We can insert new resources with the method insert_resource, which will automatically resolve type id and box data. Once we have resources inserted, we can retrieve them using the method resource<Resource + 'static>. What is worth mentioning here is that 'static does not mean a resource doesn't need a static lifetime; it's okay if it's just owned data.

Here, even more, magic is happening. Using the Rust type id system, we retrieve the pointer to data, and then we ask Rust to give us either our data in the form of the struct we expect or None. Lastly, we borrow those data and unwrap them.

You may think unwrap() is a red flag, but this can only happen if data is unavailable in HashMap. It would mean only that Game is in the wrong state, and we can't handle this situation.

Game setup with ECS

Once the initial game instance is created, we add resources and systems.

let mut game = Game::new(&mut engine).await;

game.system(systems::observe_shift);
game.system(systems::handle_move_button);
game.system(systems::mouse_click_target);
game.system(systems::select_units);
game.system(systems::clean_click_target);
game.system(systems::update_mouse_position);
game.system(systems::change_camera_position);

game.insert_resource(SelectBuffer::default());
game.insert_resource(MousePosition::default());
game.insert_resource(ClickedComponent::default());
game.insert_resource(CameraManipulation::default());
game.insert_resource(ShiftDown::default());
game.insert_resource(Actors::new(scene));

Running ECS

We must detach the state from the Game to have mutable reference to data, and then we call each system against the state to update the Game state. Lastly, we mount the state back.

impl Game {
    pub fn update(&mut self, event: &Event<()>, engine: &mut Engine) {
        let mut state = match self.state.take() {
            Some(state) => state,
            _ => return,
        };

        for system in self.systems.iter_mut() {
            system(&mut state, engine, event);
        }

        self.state = Some(state);
    }
}