• Hey Guest! Ever feel like entering a Game Jam, but the time limit is always too much pressure? We get it... You lead a hectic life and dedicating 3 whole days to make a game just doesn't work for you! So, why not enter the GMC SLOW JAM? Take your time! Kick back and make your game over 4 months! Interested? Then just click here!

An Attempt at ECS in GMS (kind of)

S

SpaceDoctor

Guest
Hi all, first post here on the forums. I'm not sure if this is the right board, as it's not exactly a problem I'm facing, but instead something I've developed and want to share. For fun, I've lately been working on implementing a type of entity component system (ECS) in GMS. I want to stress that this is probably not very practical, and is more of an exercise to see if it could work right now.

My reason for this was that I eventually ran into the inevitable code spaghetti that one finds themselves with after numerous, complicated inheritances. Rather than try to find a way to get it to work in the current system, I decided to try and find a way to insert components into objects as I wanted. For the following code snippets, I've removed any error checking for simplicity.

Here's what I came up with.

Components (object)
-variables: type (enum), data (anything), manager (object)

Entities (object)
-variable: component map (ds_map)

Systems (object)
-variables: type (enum), entity list (ds_list)

In order to get everything working, I also have a global, persistent ECS object that creates and manages all systems. It's create event looks like this:
Code:
system_map = ds_map_create();

// systems
add_system(id, o_sys_transform);
add_system(id, o_sys_static_collider);
add_system(id, o_sys_kinetic_collider);
add_system(id, o_sys_controller);
Here we are adding system objects with the script, add_system, which creates a new object from an input object index and adds it to its system map.
Code:
///@param ecs_id
///@param system_object_index

var ecs_ = argument0;
var system_index = argument1;
var system_ = instance_create_layer(ecs_.x, ecs_.y, "Instances", system_index);   
system_.persistent = true;
ds_map_add(ecs_.system_map, system_.type, system_);
ds_map_add(global.system_ID_map, system_.system_id, system_);
Entities are also just objects that contain a ds_map for their components. A create event might look like:
Code:
event_inherited();

// components
ent_add_component(id, o_comp_transform); 
ent_add_component(id, o_comp_kinetic_collider);

// variables
move_speed = 60/room_speed;
Here we are adding a couple components to this object. This object also inherits from an object that only contains a ds_map called component map, which occurs in event_inherited(). Let's look at what happens in the add_component script:
Code:
///@param entity
///@param component_object_index

var entity_ = argument0;
var component_index = argument1;
var component_ = instance_create_layer(entity_.x, entity_.y, "Instances", component_index);
ds_map_add(entity_.component_map, component_.type, component_);
ds_map_add(global.component_ID_map, component_.component_id, component_);
   
// add entity to system (component's manager)
ent_add_to_system(entity_, component_.manager);
So, similar to the add_system script. Even though we are adding components here in the create event, there is nothing stopping you from adding a component later on by calling this script. In addition, we can also remove a component via ent_remove_component:
Code:
///@param entity
///@param component_enum

var entity_ = argument0;
var component_enum = argument1;

var component_map = entity_.component_map;
var component_ = component_map[? component_enum];
   
// remove the component from the map and destroy the instance
ds_map_delete(component_map, component_enum);
instance_destroy(component_);
   
// remove the entity from the system
var manager_ = component_map[? component_enum].manager;
sys_remove_entity(manager_, entity_);
This script should also show the other underlying working of this whole system. Systems and components have a variable called type that uses a global enum, for instance:
Code:
global.component_ID = 0;
global.component_ID_map = ds_map_create();

enum components
{
   none,
   transform,
   static_collider,
   kinetic_collider,
   controller
}
Lastly, the whole system comes together in the step event of the o_ECS object:
Code:
// systems that affect transform
update_sys_controller(system_map[? systems.controller]);

// normalize movement direction
update_sys_transform(system_map[? systems.transform]);

// apply and correct movement
update_sys_kinetic_collider(system_map[? systems.kinetic_collider], system_map[? systems.static_collider]);
The kinetic collider is probably the most interesting, so let's look at it:
Code:
///@param kinetic_system
///@param static_system

var sys_kinetic = argument0;
var sys_static = argument1;

var kinetic_list = sys_kinetic.entity_list;
var static_list = sys_static.entity_list;

for(var kinetic_index = 0; kinetic_index < ds_list_size(kinetic_list); kinetic_index++)
{
   var kinetic_entity = kinetic_list[| kinetic_index];
   var move_delta = ent_get_component_data(kinetic_entity, components.transform);
   
   // MOVE AND CHECK IN THE X DIRECTION //
   kinetic_entity.x += move_delta[0];
   // check the other kinetic colliders by looping through everyone except the above collider
   for(var kinetic_index_2 = 0; kinetic_index_2 < ds_list_size(kinetic_list); kinetic_index_2++)
   {
       if kinetic_index_2 != kinetic_index
       {
           var kinetic_check = kinetic_list[| kinetic_index_2];
       
           collision_detection(kinetic_entity, kinetic_check, axis.x);
       }
   }
   // check the static colliders
   for(var static_index = 0; static_index < ds_list_size(static_list); static_index++)
   {
       var static_check = static_list[| static_index];
       
       collision_detection(kinetic_entity, static_check, axis.x);
   }
   
   // MOVE AND CHECK IN THE Y DIRECTION //
   kinetic_entity.y += move_delta[1];
   // check the other kinetic colliders
   for(var kinetic_index_2 = 0; kinetic_index_2 < ds_list_size(kinetic_list); kinetic_index_2++)
   {
       if kinetic_index_2 != kinetic_index
       {
           var kinetic_check = kinetic_list[| kinetic_index_2];
       
           collision_detection(kinetic_entity, kinetic_check, axis.y);
       }
   }
   // check the static colliders
   for(var static_index = 0; static_index < ds_list_size(static_list); static_index++)
   {
       var static_check = static_list[| static_index];
       
       collision_detection(kinetic_entity, static_check, axis.y);
   }
}

if not ds_exists(sys_kinetic.entity_list, ds_type_list) or not ds_exists(sys_static.entity_list, ds_type_list)
{
   var message = "System entity lists have been destroyed!"
   show_error(message, true);
}
This should give you an idea of how it generally works for all systems: we loop through all the entities that are being managed and update them accordingly. The above script works by checking collisions between moving objects (kinetic colliders) and static colliders, and therefore must do a double pass over the kinetic colliders.

Takeaway
It seems that getting a rough ECS is somewhat possible in GMS. With my current system, you can add basically any type of data you want to an entity, although I'm not sure where that all breaks down. The default data variable for components is set to none in the parent component, and then is defined in each child component. I currently have data being an array, boolean, and a game object with no problems accessing the data variable from entities and systems.

However, there is one important caveat that I had to learn the hard way: updating an objects components in its step event will break the entire system. Therefore, this approach works only if you contain everything in systems and update scripts, and leave the step events for object variables and state machines perhaps.

I hope you enjoyed letting me ramble and go through some of my code. I would love to hear anyone's thoughts on this, or maybe a way that this approach could be improved.
 

samspade

Member
I don't know enough about ECS to comment, but I do think this is interesting. Hopefully people with more experience will give feedback.

I have used various versions of components but always locally. For example, in one project I have a health component. Objects like ships can have or not have health and the ship object doesn't care and nothing interacting with the ship cares either. Health is a separate object which can be added as a component to the ship. It controls the health, handles destruction, etc.
 
S

SpaceDoctor

Guest
That's basically exactly what this is!
Funnily enough this started in a similar way, where I had a stats object that controlled an object health, stamina, etc., but then realized that I could try to make the whole thing as generic as I can. Now, as long as the component object is defined with a type and manager you can define a component object as whatever you want.
I'm working on adding a 'debug' sort of object that will allow me to click on any entity and view/remove/add its components. A current problem is that removing the transform component first breaks the controller and collision systems, as they use it. I need to find a way to make it impossible to remove certain components if certain systems exist first.
 
S

SpaceDoctor

Guest
for anyone interested in this, there's a much more elegant solution to adding components than my stupid enum method:
Code:
if ds_list_find_index(global.component_id_list, _component_object) == -1
{
   ds_list_add(global.component_id_list, _component_object);
}
var _component_index = ds_list_find_index(global.component_id_list, _component_object);
you can create a list of existing component object indexes and use this list index as the index to store components in entity maps. Made this change throughout and the whole system works the same. Now any component can be added without having to update the component enum list. For instance, to access an entity's transform component:
Code:
var _index = ds_list_find_index(global.component_id_list, o_comp_transform);
var move_delta = _entity.component_map[? _index].move_delta;
I also got rid of the 'data' variable in the components and let the object hold whatever it wants. That way we just access the component from the map and then access whatever instance variables it owns.
 
Top