Jecs - Optimizing declarative scene graphs with ECS

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.

96 Likes

Can we have a full tutorial on this?

2 Likes

Check out the get started guide!

2 Likes

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

1 Like

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

1 Like

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

3 Likes

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.

6 Likes

All of it.

Joking, but if you have time to spare. Could you make a real game example like the demo you made, but it is complete for people to learn from and that would be pretty great since most of the roblox developers nowadays are using OOP and Event Driven pattern. So this module might give them a chance to learn what is ECS and how Data Driven pattern works. If used correctly, they can make a whole new world inside Roblox.

1 Like

Sure I might work on a tower defense game. No promises though. Generally I don’t believe there is a correct way of writing games.

3 Likes

only ten replies? feel like this should’ve been posted as soon it was a topic on OSS
using an ECS is a gem after suffering so much due to the limits OO. instantly understood that it is what i’ve been looking for so long after understanding most of the concepts.

enjoyed watching the unlisted GDC Overwatch presentation mentioned in the post a month before. Explains the benefits, singleton usage and even networking with ECS.

on my side, using it in my main project, finally away from the hard limits of OO. entities don’t have to be 10k lines classes with each handle logic for different type “accessory”, custom r6 inverse kinematics handled with queries on frame, entity_admin allows for playback demos to be possible…

thank you Markus and all other contributors for JECS. Matter seemed a bit too bloated at the time and… unnecessary abstract i guess? for the ECR mentioned in the benchmark - missed it, never heard of it.
and this. this just hits the spot.

I made this post only after I had decided that it was completely ready for production usage. I have now used jecs in my own games and seen many testimonies from other games in development that there has not been any jecs-related errors.

2 Likes

Hello, i think you forgot to add .rbxm file onto new releases past v0.5.1, or is it intentional?

Hm the actions/upload-artifact is probably outdated. I have been getting tons of emails about it lol. Anyways the module is in a single script so it is not that bad to just copy-paste the source IMO.

1 Like

i assume i don’t want to set up too many entity relationships to not mess with memory access patterns? i’m thinking about having a “player” entity with a bunch of entities that have childof pairs connected to the main player entity

Your intuition is absolutely correct that entity relationships will pessimize certain things. But data access patterns should not be affected provided that you are utilizing the relationships for more targetted queries. If not, then yes it will likely cause higher fragmentation in queries.

do you have any idea how to make “targeted queries”? like entity relationships for an entity belonging to a part in the workspace?
edit: how would you handle damage events? currently my solution is to create a bunch of “damageupdate” entities connected via entity relationships because i’m planning for them to have complicated stuff like elemental resistances/weaknesses (so no stacking damage numbers together)

A targetted query is just world:query(HitRequest, pair(ChildOf, $target)), which ensures you only iterate over hit request entities that have the $target parent. It is really fast!

Your suggested example is actually excellent and I will explain why. First we need to dispel a myth that in ECS you are somehow iterating over things unnecessarily every frame, incurring unnecessary overhead. This could not be further from the truth. Queries provide a way to remove unnecessary querying about whether or not to process your data. Instead of checking whether something is nil, queries ensures that every entity that matches against your terms will always guarantee that it is in a valid state and should be processed. Similarly, damage events is actually really simple if you think about it. Just spawn an entity that says it should hit somebody such as HitRequest { source, target, damage, kind }, next time a query that matches against this component will iterate it and as you apply said damage onto target. After you are done, you can just delete the entity so it will no longer be processed. Easy.

I will close this off with an aphorism of sorts:
When in doubt you should make entities because they are dirt cheap. Especially when you have nice normalized child entities that can have independent behaviours instead of spending a lot of time on handling special cases.

To share an anecdote, I make spatial sound effects entities, because just like any other renderable mesh with a transform, they need to exist somewhere in worldspace and there is no reason for it not to be able to share behaviour with other systems that interact with Transforms. This has greatly helped simplify things in my games.

3 Likes

ok, just to be ABSOLUTELY sure, i can make a damage system by having hitrequest entities be fetched by systems that iterate over entities with health components by using pair(ChildOf, entity-i’m-damaging) as the hitrequest’s “target”, and when i get access to each hitrequest entity, i also send info to the entity for the “source” component…how do i get the entity for the “source” component? do i just store a direct reference to the entity, or do something like pair(ChildOf, entity-that’s-dealing-the-damage)?

There are a bunch of ways you can encode it, and you do not necessarily need to do it one way or the other and I am not going to give you prescriptions for the best way.

Having a component such as { source: jecs.Entity, target: jecs.Entity } is just as valid instead of representing with relationships if you don’t intend to make targeted queries.

As to what you should query in systems, that also depends on you again. But I will give you an optimization tip for free; normally inner for loops are going to be multiplicative as no matter which way you do it, you will iterate the same number of times. But queries may be different. E.g. you may have a lot of entities with health but only ever a couple of hits. You don’t want to test a lot of entities unnecessarily to see if they have any associated hits. Instead you want to look if there are any hits you need to process at this frame and then homogenously handle their source and targets.
This is an example of how to think about the data instead of the object and their semantics!

4 Likes