ECS Survivors Part VI: Code Refactor

ECS Survivors is starting to come a long way, and as many software projects code smells start to emerge and it can become increasingly difficult to add or modify features. I found this to be a rising concern, especially in my modules. The gameplay modules particularly has over 500 lines of code and becomes difficult to find specific systems or add new ones. Well, in this blog post I'll explain the improvements I have made to help with code maintainability.

To start we will move the system lambda functions into their own files. The spawn enemies system will go from this:

world.system<const Spawner, const core::GameSettings>("Spawn Enemies")
                .tick_source(m_spawner_tick)
                .term_at(1).singleton()
                .each([&,world](flecs::entity self, const Spawner &spawner, const core::GameSettings &settings) {
                    const flecs::entity e = world.lookup(spawner.enemy_prefab_name.c_str());
                    if (world.query<physics::Collider>().count() > 5000) return;

                    if (0 == e) return;

                    float factor = rand() % 2 - 1;
                    float neg = rand() % 1 - 1;
                    float randX = outside_side_switch
                                      ? neg * factor * settings.windowWidth
                                      : rand() % settings.windowWidth;
                    float randY = outside_side_switch
                                      ? rand() % settings.windowHeight
                                      : neg * factor * settings.windowHeight;

                    world.entity().is_a(e).child_of(self)
                            .set<core::Position2D>({randX, randY});

                    outside_side_switch = !outside_side_switch;
                });

To this:

world.system<const Spawner, const core::GameSettings, const rendering::TrackingCamera>("Spawn Enemies")
                .tick_source(m_spawner_tick)
                .term_at(1).singleton()
                .term_at(2).singleton()
                .each(systems::spawn_enemies_around_screen_system);
inline bool outside_side_switch = false;

    inline void spawn_enemies_around_screen_system(flecs::iter &iter, size_t i, const Spawner &spawner,
                                                   const core::GameSettings &settings,
                                                   const rendering::TrackingCamera &camera) {
            float factor = rand() % 2 - 1;
            float neg = rand() % 1 - 1;
            float randX = outside_side_switch
                              ? neg * factor * settings.windowWidth
                              : rand() % settings.windowWidth;
            float randY = outside_side_switch
                              ? rand() % settings.windowHeight
                              : neg * factor * settings.windowHeight;
            outside_side_switch = !outside_side_switch;

            iter.world().entity().is_a(spawner.enemy_prefab).child_of(iter.entity(i))
                    .set<core::Position2D>({randX, randY});
        
    }

Of course the function's code does not magically disappear, we just create a brand new .h file, called spawn_enemies_around_screen_system. I'll also insist on each system file name to end with _systems.h for clarity sake. Once all of the systems are into their own files, I also want to move all queries into their own as well, and create a new initialisation phase for the queries. I found myself creating queries and using similar ones in different systems, so might as well make them accessible across modules.

// base module

BaseModule(flecs::world &world): m_world(world) {
        std::cout << "Creating Module " << typeid(T).name() << std::endl;
        // Register the instance
        world.module<T>();
        static_cast<T *>(this)->register_components(world);
        static_cast<T *>(this)->register_queries(world);
        static_cast<T *>(this)->register_pipeline(world);
        static_cast<T *>(this)->register_systems(world);
        static_cast<T *>(this)->register_submodules(world);
        static_cast<T *>(this)->register_entities(world);
    }

void register_queries(flecs::world &world) {
        std::cout << "No query registration implemented" << std::endl;
    }


// physics module
void PhysicsModule::register_queries(flecs::world &world) {
        queries::visible_collision_bodies_query = world.query_builder<core::Position2D, Collider>().with<
            rendering::Visible>().build();

        queries::box_collider_query = world.query_builder<core::Position2D, Collider>().with<BoxCollider>().build();
    }

// physics/queries.h
inline flecs::query<core::Position2D, Collider> visible_collision_bodies_query;
    inline flecs::query<core::Position2D, Collider> box_collider_query;

That's about it for the refactor part I believe might have forgotten a few minor changes, but with these two it is a bit more pleasant to work on ECS Survivors.

I also took the opportunity to make the collision system threaded. While my solution is not optimal, it does increase performance significantly. First we can add the .multithreaded() annotation so that Flecs automatically separates the entities between threads. Next in the collision system we create a new "Filter", which is a query that is not updated by component changes, to check overlap with every other entity, and add possible collisions to a local list of collisions. Where the threading logic differs is when we synchronise all of the possible collision into a single list. We use a mutex to ensure only one thread is writing at a time. Now I have mentioned it above, but this solution is probably not the most optimal, since a lot of the time the threads will be waiting for the mutex to be unlocked. I don't how much impact it has, but I know it slows it down.

world.system<CollisionRecordList, const core::Position2D, const Collider>(
                    "Detect Collisions ECS (Naive Record List) non-static")
                .term_at(0).singleton()
                .with<rendering::Visible>()
                .kind<Detection>()
                .multi_threaded()
                .tick_source(m_physicsTick)
                .each(systems::collision_detection_non_static_system);

// detection system file

static std::mutex list_mutex;

    inline void collision_detection_non_static_system(flecs::iter &self_it, size_t self_id, CollisionRecordList &list,
                                           const core::Position2D &pos,
                                           const Collider &collider) {
        std::vector<CollisionRecord> collisions;
        std::vector<CollisionRecord> events;
        flecs::world stage_world = self_it.world();

        // Build a staged query, and filter
        auto visible_query = stage_world.query_builder<const core::Position2D, const Collider>()
            .with<rendering::Visible>().filter();
        flecs::entity self = self_it.entity(self_id);

        visible_query.each(
            [&](flecs::iter& other_it, size_t other_id, const core::Position2D &other_pos,
                const Collider &other_collider) {
                flecs::entity other = other_it.entity(other_id);
                if (other.id() <= self.id()) return;

                if ((collider.collision_filter & other_collider.collision_type) == none) return;

                Rectangle self_rec = {
                    pos.value.x + collider.bounds.x, pos.value.y + collider.bounds.y, collider.bounds.width,
                    collider.bounds.height
                };
                Rectangle other_rec = {
                    other_pos.value.x + other_collider.bounds.x, other_pos.value.y + other_collider.bounds.y,
                    other_collider.bounds.width, other_collider.bounds.height
                };

                if (CheckCollisionRecs(self_rec, other_rec)) {
                    collisions.push_back({self, other});
                }
            });


        // not ideal, there is a bit of loss of time because of the lock
        list_mutex.lock();
        list.records.insert(list.records.end(), collisions.begin(), collisions.end());
        list_mutex.unlock();
    }

That all there is to it for the blog post, quite a short one if we compare it to the others, but it feels nice to do a bit of gardening in my code if only to help myself in the future. Next time, I want to introduce an environment, which will also require to rework on the collision systems to support different collision shapes collision detections. Until then, you can check out the source code on Github and you get your hands on latest build on Itch.io