Jecs - Optimizing declarative scene graphs with ECS

MXQqzqa

License: Apache 2.0 Wally

Jecs

Just a stupidly fast Entity Component System

  • Entity Relationships as first class citizens
  • Iterate 800,000 entities at 60 frames per second. (More if you inline via query:archetypes)
  • Type-safe Luau API
  • Zero-dependency package
  • Optimized for column-major operations
  • Cache friendly archetype/SoA storage
  • Rigorously unit tested for stability

For more information, check the documentation!


The debugger in the picture is a 3rd party library called jabby, get it under the assets page

Quick Example

local jecs = require("@jecs")
local world = jecs.World.new()

-- Define Position and Velocity components
local Position = world:component() :: jecs.Id<Vector3>
local Velocity = world:component() :: jecs.Id<Vector3>

-- Define the Move system
local function move(dt)
    for e, pos, vel in world:query(Position, Velocity) do
        pos += vel * dt

        world:set(e, Position, pos)  -- Update the position in the world
    end
end

-- Create an entity with Position and Velocity components
local entity = world:entity()
world:set(entity, Position, Vector3.new(1))
world:set(entity, Velocity, {x = 1, y = 2})

-- Call the Move system
move(1/60)

More examples can be found here. I specifically recommend looking at the examples folder that show usage of the API for different scenarios. The demo is a good overview of how a cohesive setup could look like.

Relations

Jecs provides first class entity relationships which are useful for scene hierarchies, graphs, physics joints and more in a safe, fast and ergonomic way working with the ECS rather than fighting against it.

Relations can be both stateless or have associated data, like spring or joint strengths.

Relations provide three categories of benefits:

  • Ergonomics: query for entities with specific relation types
  • Correctness: enforce logic invariants (such as “each child must have a parent” or “each child can only have at most one parent”). In addition, relations are cleaned up on deletion.
  • Performance: iterating children is cache friendly and does not require random access.
local world = jecs.World.new()
local pair = jecs.pair
local Name = world:component() :: jecs.Id<string>
local Position = world:component() :: jecs.Id<Vector3>
-- Regular entities are tags (zero-sized-types)
local Star = world:entity() 
local Planet = world:entity()
local Moon = world:entity()

local sun = world:entity()
world:add(sun, Star)
world:set(sun, Position, Vector3.one)
world:set(sun, Name, "Sun")

	local earth = world:entity()
	world:set(earth, Name, "Earth")
	world:add(earth, pair(ChildOf, sun))
	world:add(earth, Planet)
	world:set(earth, Position, Vector3.one * 3)

		local moon = world:entity()
		world:set(moon, Name, "Moon")
		world:add(moon, pair(ChildOf, earth))
		world:add(moon, Moon)
		world:set(moon, Position, Vector3.one * 0.1)

See the guide or examples for more information.

What is an ECS?

Entity Component System (ECS) is a design approach that promotes code reusability by separating data from behavior. An ECS typically have the following characteristics:

  • It has entities, which are unique identifiers
  • It has components, which are plain datatypes without behavior
  • Entities can contain zero or more components
  • Entities can change components dynamically
  • It has systems, which are functions matched with entities that have a certain set of components.

Who is using an ECS?

A number of notable projects that you may know utilize it in some way include:

Is ECS Fast?

A common question is: How does cache locality and hardware optimization matter in a language like Luau where all userdata and lua tables are pointers? You might assume that hardware optimizations, such as laying out data contiguously, would be irrelevant. However, benchmarks show that memory access patterns are still incredibly important. I’ve benchmarked several ECS implementations against jecs, and despite degraded locality conditions due to high memory usage, jecs consistently performs well. This is because it fetches archetypes into CPU caches which minimizes random access, whereas other implementations struggle during naive iterations because their data in non-contiguous structures.

For a more isolated example, you can test the difference between linear and random access orders in a synthetic benchmark. Under normal conditions, linear access can be nearly an order of magnitude faster than random access. The difference becomes even more pronounced under degraded locality conditions. In my benchmark, I used tables as objects since the TValue on the Lua stack points to heap objects, which in turn point to heap hashmaps. These layers of indirection extrapolate the cost of randomized access.

image

The key takeaway is while Luau doesn’t guarantee contiguous memory allocation for tables, cache locality can still improve when tables are created in bulk which may also lead Luau’s memory allocator to place them closer together. If pointers to these tables are stored contiguously in a parent table, the CPU can load their addresses efficiently, minimizing cache misses. Even though Luau objects are reference types, modern CPUs benefit from accessing nearby memory, and prefetching can help load data more efficiently. Iterating over tables in a predictable order, especially in systems like ECS, can therefore optimize memory access and reduce latency.

Another advantage of ECS is that it allows you to process entities with specific combinations of components in bulk, reducing the need for branching logic. This is a significant improvement, as it minimizes unnecessary checks. It can also help with memory fragmentation by keeping entity data smaller and more compact which is really beneficial for the garbage collector.

However when discussing performance optimization, it’s crucial to approach any proposed solution with a healthy degree of skepticism. While ECS can be an efficient way to store and update a scene graph — allowing better CPU utilization — it won’t automatically fix all performance issues as it is not a silver bullet.

Profiling is key — ensure you profile your game to identify actual bottlenecks before making sweeping changes.

Things that ECS implementations generally excel at are querying and iterating through entities in a linear, cache-friendly manner, or dynamically adding or removing components at runtime. However, they are generally less efficient for operations requiring complex data structures, like binary trees or spatial indexing, where specialized solutions are more appropriate. Knowing the tradeoffs of an implementation and leveraging its design ensures you get the most performance out of an ECS.

FAQ

Sander Mertens has already done a terrific job of compiling answers to frequently asked questions: https://github.com/SanderMertens

More resources to read: https://github.com/SanderMertens/ecs-faq?tab=readme-ov-file#Resources

Where to get help with jecs?

You can find a thread on jecs in the Roblox OSS discord server, or open an issue on the github repository.

22 Likes

Can we have a full tutorial on this?

Check out the get started guide!

This is a really great ECS module. But I do wish for it to have a lot of documentation about using this module as its finest

Absolutely, what kind of things would you like to see documented further?

Is there any Roblox game which is released and playable right now that uses this (or just ECS in general)?

Jecs may be fast, but is it faster than making games normally? I’m also confused on how this workflow is more efficient or “better” than the more traditional way for any type of game.

It is generally beneficial for the CPU but the benefits may be less noticeable in some games.

If you want to consider using ECS, I suggest looking at it for its organizational benefits rarher than just performance.

1 Like