RECS: A Reactive ECS Framework for High-Performance Simulations in Julia

I am thrilled to introduce RECS (Reactive Entity-Component-System), a new Julia package designed for building high-performance, modular, and reactive simulations, with a focus on game development and scientific applications. RECS combines the data-oriented efficiency of ECS architectures with a reactive, asynchronous execution model, making it ideal for complex systems requiring dynamic data flows and scalability.

Repository: github.com/Gesee-y/ReactiveECS.jl
Documentation: ReactiveECS Guide | Reactive Architecture
Get Started: ]add ReactiveECS or ]add https://github.com/Gesee-y/ReactiveECS.jl for the development version

Why RECS?

RECS is built to address the limitations of traditional ECS frameworks, such as rigid update loops and static system orchestration, while leveraging Julia’s strengths for performance and flexibility. Key features include:

  • Reactive System Chaining: Use listen_to to create dynamic data pipelines between systems, enabling modular workflows without tight coupling.
  • Asynchronous Execution: Systems run as independent coroutines, supporting parallelism and real-time responsiveness.
  • Cache-Friendly Design: Components are stored in a Struct of Arrays (SoA) layout, optimized for vectorization and SIMD (via LoopVectorization.jl).
  • Dynamic Extensibility: Add or modify systems at runtime, ideal for iterative scientific experiments.
  • Fast Entity Management: Entity pooling and precomputed archetypes ensure low overhead (e.g., ~869 ns to add an entity, ~168 ns to remove).

RECS in Scientific Computing

RECS excels in scientific simulations where large datasets and complex interactions are common. Below is an example of using RECS for a molecular dynamics simulation, modeling particles under gravitational forces—a common task in computational physics and chemistry.

Example: Molecular Dynamics Simulation

This example simulates a system of particles interacting via gravitational forces, with components for position, velocity, and mass, and systems for force computation and motion integration.

using ReactiveECS
using LinearAlgebra

# Define components
@component Position begin
    x::Float64
    y::Float64
end

@component Velocity begin
    vx::Float64
    vy::Float64
end

@component Mass begin
    m::Float64
end

# Define systems
@system ForceSystem
@system MotionSystem

# Compute gravitational forces between particles
function ReactiveECS.run!(::ForceSystem, ref::WeakRef)
    indices = ref.value
    positions = get_component(world, :Position)
    masses = get_component(world, :Mass)
    forces = zeros(Float64, length(indices), 2)  # Store forces [fx, fy]

    @inbounds for i in eachindex(indices)
        for j in i+1:length(indices)
            idx1, idx2 = indices[i], indices[j]
            dx = positions[idx2].x - positions[idx1].x
            dy = positions[idx2].y - positions[idx1].y
            r = sqrt(dx^2 + dy^2)
            if r > 1e-6  # Avoid division by zero
                G = 6.67430e-11  # Gravitational constant
                force = G * masses[idx1].m * masses[idx2].m / r^2
                fx = force * dx / r
                fy = force * dy / r
                forces[i, 1] += fx
                forces[i, 2] += fy
                forces[j, 1] -= fx
                forces[j, 2] -= fy
            end
        end
    end
    return forces
end

# Update velocities and positions
function ReactiveECS.run!(sys::MotionSystem, data)
    indices = get_indices(world, sys) # Returns the indices of the entities matching the subscription of the system
    forces = data  # Received from ForceSystem
    velocities = get_component(world, :Velocity)
    positions = get_component(world, :Position)
    masses = get_component(world, :Mass)
    dt = 0.01  # Time step

    @inbounds for i in eachindex(indices)
        idx = indices[i]
        # Update velocity (F = ma => a = F/m)
        velocities[idx].vx += forces[i, 1] / masses[idx].m * dt
        velocities[idx].vy += forces[i, 2] / masses[idx].m * dt
        # Update position
        positions[idx].x += velocities[idx].vx * dt
        positions[idx].y += velocities[idx].vy * dt
    end
end

# Setup
world = ECSManager()
force_sys = ForceSystem()
motion_sys = MotionSystem()

subscribe!(world, force_sys, (PositionComponent, MassComponent))
listen_to(force_sys, motion_sys)

run_system!(force_sys)
run_system!(motion_sys)

# Create 100 particles with random positions and masses
for i in 1:100
    create_entity!(world;
        Position=PositionComponent(rand() * 10, rand() * 10),
        Velocity=VelocityComponent(0.0, 0.0),
        Mass=MassComponent(1.0 + rand() * 10)
    )
end

# Simulate for 10 frames
for frame in 1:10
    println("Frame $frame")
    dispatch_data(world)
    blocker(world)
end

Why RECS for Science?

  • Scalability: RECS’s SoA layout and vectorization support (e.g., @inbounds, @turbo) make it ideal for large-scale simulations, like modeling thousands of particles.
  • Reactivity: The listen_to mechanism allows systems to chain computations (e.g., forces → motion), mimicking the iterative workflows of scientific simulations.
  • Flexibility: Add new systems (e.g., a visualization system) at runtime without modifying existing code, perfect for iterative experiments.
  • Performance: Benchmarks show RECS handles 10K entities in ~766 ÎĽs for updates, outperforming other Julia ECS frameworks like Overseer.jl for large datasets.

For more examples, including Conway’s Game of Life and physics simulations, check our case studies.

Get Involved

We’re excited to see how the community uses RECS for games, simulations, and beyond! Here’s how you can contribute:

  • Report bugs or suggest features via GitHub issues.
  • Contribute code to enhance RECS’s features or add new examples.
  • Share your projects using RECS in the Discussions tab.

Try RECS today and let us know your feedback! For technical details, see our reactive architecture article or dive into the user guide

20 Likes

Very nice to see a high-performance ECS implementation!

A question about the design, if I may - I would expect constructs like

positions = get_component(world, :Position)

to make the ReactiveECS.run! methods type-unstable. Is this not relevant, performance-wise? Or is there some const-propagation magic under the hood?

There will be no problem but positions will contain a StructArray
ReactiveECS.run! is made to run with concrete type, you should try modifying field instead of interacting directly with positions and since we are using StructArray.jl, when you will try getting a field (for example position.x), it will return a vector of the position.x of each component. The problen now is that julia cannot infer the type of the data contained in that vector, leading to unnecessary allocations. so you should annotate the type

positions = get_component(world, :Position)
x_positions::Vector{Float32} = positions.x # Now you can get the x position of every entity
# without losing performances

But if you want to be sure that you run! function is type stable, you can use FunctionWrappers.jl


using FunctionWrappers

struct MySystemRun
    # FunctionWrapper will ensure that the function is type stable
    fun::FunctionWrapper{Nothing, Tuple{WeakRef}}
end

function run_my_system(ref) # ref is a weak ref to the vector of indices
    # My process
end

const MyRun = MySystemRun(run_my_system)
ReactiveECS.run!(::MySystem, ref) = MyRun.fun(ref)

Interesting, but I’m curious on what exactly is behind the hood such to make a package like this useful. I explain: in the example you show here, of a particle simulation, most of the time would be spent in computing the forces, which is something that is dealt with using specialized algorithms, and has to be parallelized at that level. The structure of the code, in base Julia, would not be too different, if we define custom structures for the particles, for example.

What benefits would I get from using such a system, in comparison with a bare bones implementation of a particle simulation (like this, for example)?

2 Likes

It’s a good question.
In fact, the main benefit of this package is structural rather than algorithmic.
The point is to fully decouple the logic of your program. In the example above, the logic computing forces is completely separate from the one that integrates motion. This is particularly important when you have dozens of other systems (rendering, collisions, AI, etc.), and you want them to remain easy to compose or swap.

Another design goal is making parallelization easier. That’s exactly why the system returns the indices of the relevant entities and why components/entities are internally represented as contiguous arrays. This makes it trivial to split work across threads. For example:

chunk_size = 100
forces = zeros(Float64, length(indices), 2)
chunks = Iterators.partition(indices, chunk_size)
results = map(chunk -> Threads.@spawn calculate_force!(forces, chunk), chunks)
fetch.(results)  # wait for all chunks to finish

There’s no merging step needed because all tasks directly update the shared forces buffer by index.

Sure, for a one-off particle sim, you could do something similar with plain structs. But as the system scales — with more behaviors, more data types, more interactions — having this ECS backbone means you don’t end up with a single tangled update loop. That’s where the architecture really pays off.

2 Likes

But what would be the function to calculate forces there? (again, maybe the example of the particle simulation above was unfortunate for my understanding)

Oh, I see. My bad.
Yeah, we will need a merge, since there are risk for race conditions. We would need to create buffer for each chunk and merge the results.
Meaning, the run! function is actually just like a main function in C.
You can implement your calculations the way you want, the point is to let you do that without mixing up logic, MotionSystem’s logic doesn’t interfer with ForceSystem’s logic. And also let you add new system easily, for example if you want to add a new DragSystem, you would just configure his logic and then just add it into your existing workflow with little to no change in your existing code.
And this modularity doesn’t come at the cost of performances. Data (or more precisely indices) are efficiently delivered to the systems in a way that simplify parallelism and scalability.

2 Likes

Would a construct like

positions = get_component(world, Val(:Position))

be possible to make things type-stable without explicit type-annotations?

I wonder if, by specializing getindex and getproperty and so on, one could turn this into something like

function ReactiveECS.run!(world, sys::MotionSystem, data)
    E = world[sys] # "filtered" entity references, kind of a virtual StructArray

    # via `getproperty` specialization:
    velocities = E.Velocity
    positions = E.Position
    masses = E.mass

    # Virtual StructArray-like behavior:
    # E.Velocity[idx] == E[idx].Velocity

    @inbounds for idx in eachindex(E)
        # ...
    end
end

(with the same happening under the hood as before, just nice syntactic sugar on top)?

Nice idea.
I will work on that.
It would greatly simplify the syntax

Cool! I’m excited to see where this goes, I think there’s interesting applications for ECS esp. in scientific simulations (where the approach is often not well know yet).

I think lmiq is trying to say that it would be possible to define a struct Particle that contains Position, Velocity, Mass fields and for the algorithm to deal with forces and motion separately too. That seems true, could even use StructArray.jl for component-wise storage. The standout difference to me is that ECS doesn’t demand a particular struct to implement an entity, only components (at least that’s what @component seems to be doing). We only needed to deal with masses and positions in the force system and masses, positions, and velocities in the motion system; the systems have no idea what a particle is, and there are no methods specifically for particles. If there were, the Particle methods would just extract the components, do the real work on the components, and rewrap them into Particles for no real benefit. Intuitively, this opens up situations where there isn’t a manageable set of fixed structures. Maybe the game lets players put any number of wheels on a car, not just 4, and the car itself is already made of arbitrary pieces. Making distinct types, even nameless ones like NamedTuples, for every idea of a car players can imagine is a nightmare there and unnecessary because it’s the components’ behaviors and the emergent activity that matter.

Minor question, how are these mutations via field reassignment possible when the macro seems to be making immutable structs?

macro component(name, block)
	struct_name = Symbol(string(name)*_default_suffix())
	ex = string(name) # Just to interpolate a symbol

	# Our struct expression
	struct_ex = Expr(:struct, false, :($struct_name <: AbstractComponent), block)
    ...
1 Like

In fact, a StructArray doesn’t actually keep the struct in memory, it keep a vector or each field. so if I push! a struct in a StructArray, each field’s value will go to their respective vector. Meaning whetever the struct is mutable or immutable, since it’s not the struct that’s directly stored, it doesn’t matter (but keeping struct immutable make the StructArray reconstruct that struct quicker)

1 Like

Just checking if I’m reading that right. It’s not that an entity is deconstructed and stored into struct of arrays of its components, but rather each component is deconstructed and stored into a struct of arrays of its fields?

Yeah, and each struct of array of component is stored in a dict via their name. In fact, an entity is much just an indice. It indicate a which position the data of the entity are

1 Like

If you’re interested, I alredy updated the syntax and optimized performances. Here is now the form of a typical run!:

using RECS

# This will create a new component
# And set boilerplates for us
# The constructor is just the name passed here plus the suffix "Component"
@component Health begin
    hp::Int
end

@component Transform begin
    x::Float32
    y::Float32
end

@component Physic begin
    velocity::Float32
end

# We create a system
@system PhysicSystem begin
    delta::Float32
end
@system RenderSystem

# The system's internal logic
# Each system should have one
function RECS.run!(world, sys::PhysicSystem, data)

    E = world[sys] # First we get some sort of wrapper for ease of use
    indices::Vector{Int} = data.value # The indices of the matching entities
    L = length(indices)

    # Next we get the components
    transforms = E.Transform
    physics = E.Physic

    # We will just work on the x axis
    # Type unstability has been fixed, no more need to specify the types
    x_pos = transforms.x
    velo = physics.velocity
    dt::Float32 = sys.delta

    @inbounds for i in indices
        x_pos[i] += velo[i]*dt
    end
end

function RECS.run!(_, ::RenderSystem, pos) # Here `pos` is the transform_data we returned in the PhysicSystem `run!`
    for i in eachindex(pos)
        t = pos[i]
        println("Rendering entity at position ($(t.x), $(t.y))")
    end
end

ecs = ECSManager()

physic_sys = PhysicSystem(1/60)
render_sys = RenderSystem()

subscribe!(ecs, physic_sys, (TransformComponent, PhysicComponent))
listen_to(physic_sys,render_sys)

# Creating 3 entity
# We pass as keywork argument the component of the entity
e1 = create_entity!(ecs; Health = HealthComponent(100), Transform = TransformComponent(1.0,2.0))
e2 = create_entity!(ecs; Health = HealthComponent(50), Transform = TransformComponent(-5.0,0.0), Physic = PhysicComponent(1.0))
e3 = create_entity!(ecs; Health = HealthComponent(50), Transform = TransformComponent(-5.0,0.0), Physic = PhysicComponent(1.0))

# We launch the system. Internally, it's creating an asynchronous task
run_system!(physic_sys)
run_system!(render_sys)

N = 3

for i in 1:N
    println("FRAME $i")

    # We dispatch data and each system will execute his `run!` function
    dispatch_data(ecs)
    yield()
    sleep(0.016)
end
2 Likes