DEV Community

Cover image for Introducing Ecsact
Ezekiel Warren for Seaube

Posted on

Introducing Ecsact

Ecsact Logo

TL;DR - Ecsact (ecsact.dev) is a free and open source language and runtime standard dedicated to the Entity Component System (ECS) architecture.

If you don’t want to know how we came up with Ecsact you can just skip right to the “What is Ecsact?” section below.

Background

About ~3 years ago a friend of mine and myself set off to create a multiplayer game I will refer to as “Project ZC”. The game itself isn’t that important, but at the time I sold the Entity Component System (ECS) architecture to my friend. Our strategy for synchronizing the game simulation was to create a deterministic set of systems that run on the server and clients. Player input was handled by sending out “actions” which were just systems that could be arbitrarily executed in the game simulation. Additionally we had some mechanisms to make sure the simulation ticks were not drifting.

At first we weren’t sure which game engine to use. We debated between Unity, Unreal, Godot, and using some rust framework/engine. Based on our experiences with each and the kind of game we wanted to make, we ended up picking Unity. However we were willing to switch to something else over the next several months if it didn’t work out. Unity had ECS support as part of Unity DOTS, but it was version 0.0 at the time and it didn’t look quite ready. Ultimately we decided not to use Unity’s ECS not only because it was in early development, but because we wanted to have a standalone server that didn’t rely on anything Unity related.

Since we wanted a common game simulation that would be on both the server and the client we looked into a few libraries that would fit our ECS needs. It was decided we were going to write this common part of our game in C++, but rust was considered. C++ was a familiar language for us so naturally EnTT and flecs came up right away. I had used EnTT before, writing some small demo projects, so our choice was made based on familiarity. In order to integrate with Unity we created a small C interface to communicate between our simulation code and Unity’s C#. Here’s close to what it looked like. I removed some parts for brevity sake.

void zc_init();
void zc_quit();
void zc_auth(const char* host, const char* token);
void zc_push_action(int action_id, void* action_data);

// Event callbacks
typedef void(*zc_component_event_callback)(
int component_id, void* component_data, void* user_data);
void zc_register_init_component_callback(
zc_component_event_callback callback, void* user_data);
void zc_register_update_component_callback(
zc_component_event_callback callback, void* user_data);
void zc_register_remove_component_callback(
zc_component_event_callback callback, void* user_data);

// Flush events - triggers any pending event callbacks
void zc_flush();
Enter fullscreen mode Exit fullscreen mode

With this small API we had our C# listening to events happening to components in the simulation. For example we had a “Position” component that represented, well, the position. Simple X and Y coordinates. And for every entity we had a game object. By adding a few event listeners we were able to synchronize the game objects transform to the position component coordinates. Similarly we had an “Attacking” component and when that “Attacking” component was added an animation would trigger.

This worked well for a while. We kept adding more components and setup effects when components changed or simply synchronizing state. One thing really bothered me though. I was duplicating component type definitions in C# and in C++. One weekend I came up with a solution. I created an interface description language (IDL) dubbed “ECS IDL”. The language looked something like this:

component Position {
  int32 x;
  int32 y;
}
Enter fullscreen mode Exit fullscreen mode

Which I would feed into a code generator that would create a C# struct like this:

public struct Position {
  public const int id = 1;
  public global::System::Int32 x;
  public global::System::Int32 y;
}
Enter fullscreen mode Exit fullscreen mode

And a C++ struct like this:

#include <cstdint>
struct Position {
  static constexpr int id = 1;
  std::int32_t x;
  std::int32_t y;
};
Enter fullscreen mode Exit fullscreen mode

What I had at the time wasn’t very impressive, but it was useful! In hindsight I was overengineering the solution big time, but in my mind I figured I would add new things to the language to help with other ECS related things.

And that we did

Shortly after I gave the same treatment to our “action” concept since they could have fields like components can.

action Jump {
  int32 player_id;
}
Enter fullscreen mode Exit fullscreen mode

But it didn’t feel quite right. Actions are systems so they only run on entities with specific components. I figured “hey I could put that in the language too!”. I came up with a few keywords that made sense to me at the time.

Keyword Description
required The action implementation runs on components marked as required and can read/write to them.
readonly The action implementation can only read data from components marked as readonly. Can only be used with the required.
include The action is run on entities with components marked as include but cannot read or write them.
exclude The action skips entities with components marked as exclude.
action Jump {
  int32 player_id;

  include Grounded;
  required Velocity;
}
Enter fullscreen mode Exit fullscreen mode

I thought “why just actions, let’s put systems in there too!”

system ApplyVelocity {
  required readonly Position;
  required Velocity;
}
Enter fullscreen mode Exit fullscreen mode

I didn’t immediately have a reason to do this, but it dawned on me that I could use these declarations for execution order. I built a code generator that created EnTT views based on the system requirements and ran them in order specified by the file.

system PassiveHeal {
  required Health;
}

system PoisonDamage {
  required Health;
  include Poisoned;
}
Enter fullscreen mode Exit fullscreen mode

Those system (and action) declaration when ran through the code generator turned into something like this:

using PassiveHealView = entt::view<Health>;
using PoisonDamageView = entt::view<Health, Poisoned>;
Enter fullscreen mode Exit fullscreen mode

This kept going and going. Feature after feature I noticed I could do more things by declaring systems in a dedicated language. Much more could be said about how we got to this point, but I think you get the idea.

After playing with ECS IDL for a while we decided that this idea of a dedicated language for ECS would be useful as a general concept. We didn’t build ECS IDL to be generally used on other projects. It was built only for Project ZC. We decided to stop working on Project ZC and go full-boar trying to generalize ECS IDL as a tool anyone could use. Not only for us when we decide to start a new project, but for everyone who sees the value in it.

Over a year we spent cleaning up the language, creating a flexible runtime standard, and implementing a runtime that can utilize the users files to make it optimal based on each project. Oh! And we changed the name to Ecsact. ECS-Act. Entity Component System + Action. It also sounds like “exact” and we liked that. The rest of this article goes on to talk about Ecsact which is slightly different from the ECS IDL I described.

What is Ecsact?

Ecsact is a language and runtime standard dedicated to the Entity Component System architecture. Ecsact is not a programming language; it falls more under the category of an “Interface Description Language” with some runtime consequences, but enough with the semantics. What is Ecsact!

The Language

The Ecsact language is like a contract (and before you ask I don’t mean smart contracts - Ecsact has nothing to do with blockchain technology.) The contract specifies how your game simulation is executed. You declare your components and your systems in the Ecsact file and the simulation will run in the order your systems are declared. Here’s an example:

package example;

component Poisoned { i32 potency; } 
component Health   { i32 value; }
component Mana     { i32 value; }
component Dead;

system PoisonDamage {
    readonly Poisoned;
    readwrite Health;
    exclude Dead;
}

system PassiveHeal {
    readwrite Health;
    exclude Dead;
}

system PassiveManaRegen {
    readwrite Mana;
    exclude Dead;
}

system DeathChecker {
    readonly Health;
    adds Dead;
}
Enter fullscreen mode Exit fullscreen mode

Read more about the language syntax: ecsact.dev/docs/lang

In this example I have specified 4 components and 4 systems. The systems are executed in declaration order:

  1. PoisonDamage
  2. PassiveHeal
  3. PassiveManaRegen
  4. DeathChecker

The order can have some nuance on your simulation and it’s nice to see how your game is simulated at a high level, but that’s only a small part of what Ecsact brings.

Systems and Code Generation

Ecsact generates interfaces to implement your systems safely. Each system implementation may only access what was declared in the system block.

Systems can be implemented in C++ and Rust at this time, but any language that can be compiled into Web Assembly can theoretically work. Here’s what a C++ and rust implementation of the PoisonDamage might look like:

C++

#include "example.ecsact.systems.hh"

void example::PoisonDamage::impl(context& ctx) {
    auto poisoned = ctx.get<Poisoned>();
    auto health = ctx.get<Health>();
    health.value -= poisoned.potency;
    ctx.update(health);
}
Enter fullscreen mode Exit fullscreen mode

Rust

use my_crate::example;

#[ecsact_macro::system_impl("example.PoisonDamage")]
fn poison_dmg(ctx: &mut example::PoisonDamage::Context) {
    let poisoned: example::Poisoned = ctx.get();
    let mut health: example::Health = ctx.get();
    health.value -= poisoned.potency;
    ctx.update(&health);
}
Enter fullscreen mode Exit fullscreen mode

In either example if you tried to do something your system wasn’t allowed to do you would get a compile time error. For example, if you tried to write ctx.update(poisoned) in the PoisonDamage system implementation the compiler wouldn’t let you.

Optimized Runtime

By having strong guarantees for your system implementations the Ecsact runtime can be optimized.

  • Systems that require unrelated components can run in parallel
  • Systems that only read same components can run in parallel
  • Components can be stored optimally for a system to avoid cache misses

NOTE: Optimizing the runtime is an on-going effort. We’re actively exploring utilizing different ECS libraries and other techniques such as code generation to optimize further.

The Runtime

The Ecsact runtime standard is a set of C headers available in the ecsact-dev/ecsact_runtime GitHub repository. We chose C for maximum compatibility between languages and integration between other software (such as game engines.)

Here’s a small snippet of what the ecsact C API looks like:

// small modified snippet from core header

ecsact_registry_id ecsact_create_registry();
ecsact_entity_id ecsact_create_entity(ecsact_registry_id);
void ecsact_add_component(
    ecsact_registry_id,
    ecsact_entity_id,
    ecsact_component_id,
    void* component_data
);
Enter fullscreen mode Exit fullscreen mode

The Ecsact runtime C API encompasses everything from entity creation to executing systems. Each part of the runtime is split into “modules”. Each module has a single C header. A high level is described here in the Ecsact runtime library overview.

Fortunately this API is not something you would use directly if you’re using Ecsact. Our intention with Ecsact is to have equivalent APIs for the language and/or game engine you’re using. Unless you’re implementing your own runtime, developing an integration, or creating/maintaining bindings for a language you’ll never see these C functions.

For example in the above system implementation snippets the C++ and rust code are actually calling some Ecsact C functions such as:

// In C++ and rust ctx.get() calls this C function

void ecsact_system_execution_context_get(
    ecsact_system_execution_context*  context,
    ecsact_component_like_id          component_id,
    void*                             out_component_data
);
Enter fullscreen mode Exit fullscreen mode

System Execution

The heart of an Ecsact project involves executing your systems. In the core module there is a single function responsible for that. It accepts a few parameters which allow you to add options to that execution window and get events (feedback) from the execution.

Ecsact Execution Chart

Every time systems are executed all relevant changes are reported to the event handlers.

Event Type Event Description
Create Entity A new entity has been added to the registry either through execution options or generated by a system/action.
Init Component A new component has been added to an entity through execution options or by a system/action.
Update Component A components value has changed on an entity through execution options or by a system/action.
Remove Component A component has been removed from an entity through execution options or by a system/action
Destroy Entity An entity has been removed from the registry through execution options or by a system/action.

When integrating with a game engine or framework you listen to these events to trigger graphics, animations, particle effects etc.

Ecsact Events Demonstration

Networking (multiplayer)

The async module of the Ecsact runtime is built for synchronizing a simulation over a network. It’s designed to work out of the box with almost no changes to your code.

Executing systems doesn’t happen directly in the async module. Instead, executing systems are handled for you in the background and you’re given a function to “flush” the queued up events so that you may respond to them. A function is also given to “enqueue” execution options to be executed as soon as possible based on the async modules implementation.

Ecsact Async Execution Chart

The async module implementation is responsible for deciding the rate at which systems are executed and also provide events coming from another source (such as another player.) Only relevant events are given when a flush occurs. For example if you flush between a few execution cycles and a component was added and removed from the same entity you would get no event.

One other difference between the core system execution and the async module is a “connect” function. The connect function takes in a string in a format defined by the async module implementation. This typically would involve a way to identify which server to connect to as well as authentication.

Developing with Ecsact

We’re using Ecsact ourselves. I would argue dogfooding a project like Ecsact is the only way to make it work. We’re doing all we can to make developing with Ecsact as easy as possible.

Integrations with X

For the most part, focus on integrating with software that we use regularly is our priority. Following the instructions on ecsact.dev/start will get you the Ecsact SDK as well as instruction for integrating with a game engine.

Here are some of the tools we’ve created so far:

We’ve planned support for other engines such as Unreal and Godot and creating tools for debugging your Ecsact simulation.

Extending Ecsact

Besides contributing to the many repositories on the ecsact-dev github org the main way to extend Ecsact is by creating a code generator plugin. The ecsact command line can accept plugins built with a very simple C interface. There are also C++ and rust wrappers for creating a plugin very easily.

Future of Ecsact

Ecsact is under heavy development. We’re not at the 1.0 stable LTS version quite yet. If Ecsact seems like something you’re interested in or you’d like to weigh in your opinion on its future then we encourage you to interact with us on our Discord, follow our GitHub org, and checkout our website ecsact.dev.

We’re eager to hear from you and hope to see many projects adopt Ecsact in the future.

Top comments (0)