This commit is contained in:
Ukendio 2026-02-05 00:32:59 +01:00
parent 33f7c08025
commit d4a7f1d86c
8 changed files with 304 additions and 66 deletions

View file

@ -82,3 +82,23 @@ world:set(e1, Position, vector.create(10, 20, 30))
- [Position, Velocity] -> [Position] (for removing Velocity)
]]
local pair = jecs.pair
local Likes = world:component()
local alice = world:entity()
local bob = world:entity()
local charlie = world:entity()
local e2 = world:entity()
world:add(e2, pair(Likes, alice)) -- Creates archetype [pair(Likes, alice)]
local e3 = world:entity()
world:add(e3, pair(Likes, bob)) -- Creates archetype [pair(Likes, bob)]
local e4 = world:entity()
world:add(e3, pair(Likes, charlie)) -- Creates archetype [pair(Likes, charlie)]
world:add(e3, pair(Likes, bob)) -- Creates archetype [pair(Likes, bob), pair(Likes, charlie)]
-- Each different target creates a new archetype, leading to fragmentation
-- This is why relationships can increase archetype count significantly

View file

@ -1,9 +1,3 @@
local jecs = require("@jecs")
local pair = jecs.pair
local world = jecs.world()
local Likes = world:component()
--[[
Fragmentation is a property of archetype-based ECS implementations where entities
are spread out over more archetypes as the number of different component combinations
@ -30,19 +24,3 @@ local Likes = world:component()
pair(jecs.Wildcard, Apples) indices. For this reason, creating new archetypes
with relationships has a higher overhead than an archetype without relationships.
]]
local alice = world:entity()
local bob = world:entity()
local charlie = world:entity()
local e1 = world:entity()
world:add(e1, pair(Likes, alice)) -- Creates archetype [pair(Likes, alice)]
local e2 = world:entity()
world:add(e2, pair(Likes, bob)) -- Creates archetype [pair(Likes, bob)]
local e3 = world:entity()
world:add(e3, pair(Likes, charlie)) -- Creates archetype [pair(Likes, charlie)]
-- Each different target creates a new archetype, leading to fragmentation
-- This is why relationships can increase archetype count significantly

View file

@ -70,7 +70,7 @@ end)
local Health = world:component()
local Dead = world:component()
world:set(Health, jecs.OnRemove, function(entity, id, delete)
world:set(Health, jecs.OnRemove, function(entity: jecs.Entity, id, delete)
if delete then
-- Entity is being deleted, don't try to clean up
return

View file

@ -0,0 +1,81 @@
--[[
Signals let you subscribe to component add, change, and remove events with
multiple listeners per component. Unlike hooks (see 110_hooks.luau), which
allow only one OnAdd, OnChange, and OnRemove per component, signals support
any number of subscribers and each subscription returns an unsubscribe
function so you can clean up when you no longer need to listen.
Use signals when you need several independent systems to react to the same
component lifecycle events, or when you want to subscribe and unsubscribe
dynamically (e.g. a UI that only cares while it's mounted).
]]
local jecs = require("@jecs")
local world = jecs.world()
local Position = world:component() :: jecs.Id<{ x: number, y: number }>
--[[
world:added(component, fn)
Subscribe to "component added" events. Your callback is invoked with:
(entity, id, value, oldarchetype) whenever the component is added to an entity.
Returns a function; call it to unsubscribe.
]]
local unsub_added = world:added(Position, function(entity, id, value, oldarchetype)
print(`Position added to entity {entity}: ({value.x}, {value.y})`)
end)
--[[
world:changed(component, fn)
Subscribe to "component changed" events. Your callback is invoked with:
(entity, id, value, oldarchetype) whenever the component's value is updated
on an entity (e.g. via world:set).
Returns a function; call it to unsubscribe.
]]
local unsub_changed = world:changed(Position, function(entity, id, value, oldarchetype)
print(`Position changed on entity {entity}: ({value.x}, {value.y})`)
end)
--[[
world:removed(component, fn)
Subscribe to "component removed" events. Your callback is invoked with:
(entity, id, delete?) when the component is removed. The third argument
`delete` is true when the entity is being deleted, false or nil when
only the component was removed (same semantics as OnRemove in 110_hooks).
Returns a function; call it to unsubscribe.
]]
local unsub_removed = world:removed(Position, function(entity, id, delete)
if delete then
print(`Entity {entity} deleted (had Position)`)
else
print(`Position removed from entity {entity}`)
end
end)
local e = world:entity()
world:set(e, Position, { x = 10, y = 20 }) -- added
world:set(e, Position, { x = 30, y = 40 }) -- changed
world:remove(e, Position) -- removed
world:added(Position, function(entity)
print("Second listener: Position added")
end)
world:set(e, Position, { x = 0, y = 0 }) -- Multiple listeners are all invoked
-- Unsubscribe when you no longer need to listen
unsub_added()
unsub_changed()
unsub_removed()
world:set(e, Position, { x = 1, y = 1 })
world:remove(e, Position)

View file

@ -0,0 +1,54 @@
-- These notes are my thoughts jotted down from having experienced these
-- problems myself and gathered insights from many admired individuals such as
-- Sander Mertens, Ryan Fleury, Jonathon Blow, Benjamin Saunders and many more...
--[[
In 1993, the original source code for DOOM was about 50,000 lines of code. And
when your code gets into that neighbourhood, it should provide a large amount of
interesting and novel functionality. If it doesn't, perhaps it is time to ask questions.
Please, try to write code that is small, and that does a lot for its size.
Please, after you finish writing something, ask yourself whether you are
satisfied with how robust it is, and with how much it gets done for how much
code there is.
- Transfer of tacit knowledge is incredibly important. If tacit knowledge
about the code base is lost, ability to work on it at the same level of quality
is lost. Over time code quality will decline as code size grows.
- Tacit knowledge is very hard to recover by looking at a maze of code,
and it takes
- You will often hear that "every semantic distinction deserves its own
component or tag". Sometimes this is correct. A well chosen component boundary
can make queries clear and systems obvious. But sometimes this distinction would
be better served as a field, a bitset, or a local data structure. The
representation should match the problem.
Sub-Essay Here: Code Should Not Try To Meet Every Need Anyone May Ever Have.
Over-generalization leads to bloat and poor functionality. A common failure mode
is writing code "for everyone" while building configuration for every scenario,
abstractions for every future feature, and extension points for every imagined
consumer. It sounds responsible. It usually isn't.
Specialization is good in many cases. Think about the guy with the truck full of
automotive tools, who has a bunch of different wrenches. He doesn't carry one
wrench that transforms into every tool; he carries a few specialized tools that
are reliable and fast to use. One reason we have endless bloat is that we teach
that all code should expand until it meets all needs. This is wrong,
empirically, and we should stop teaching it.
- Relationships are very powerful however, with each pair being an unique
component, it can be an easy way to accidentally increase the number of archetypes which can cause
higher churn in systems.
- A hook is not a replacement for systems. They are for enforcing invariants when data changes during different lifecycles.
When gameplay logic that should run predictably each frame is instead scattered
across hooks, behaviour becomes implicit when it is triggered indirectly through
a cascade of changes that, logic split across many small
callbacks that fire in surprising order. Which also gets harder to reason about
and optimize.
]]

View file

@ -1,6 +1,6 @@
{
"name": "@rbxts/jecs",
"version": "0.9.0",
"version": "0.10.0",
"description": "Stupidly fast Entity Component System",
"main": "src/jecs.luau",
"repository": {
@ -37,10 +37,5 @@
"roblox-ts": "^3.0.0",
"typescript": "^5.4.2",
"vitepress": "^1.3.0"
},
"scripts": {
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
}
}

View file

@ -211,24 +211,6 @@ type world = {
removed: (world, i53, (e: i53, id: i53, delete: boolean?) -> ()) -> () -> (),
}
export type Type_Query<T...> = Query<T...>
type function QueryBundle(t1: type)
assert(t1:is("function"))
local params = t1:returns()
local head = params.head
assert(head)
local newt = {}
for i = 1, #head do
if head[i] == types.unknown then
continue
end
newt[i] = head[i]:readproperty(types.singleton("__T"))
end
return Type_Query(unpack(newt))
end
export type World = {
archetype_edges: Map<number, Map<Entity, Archetype>>,
archetype_index: { [string]: Archetype },
@ -313,7 +295,35 @@ export type World = {
children: <T>(self: World, id: Id<T>) -> () -> Entity,
--- Searches the world for entities that match a given query
query: (<A, B, C, D, E, F, G, H>(World, A?, B?, C?, D?, E?, F?, G?, H?, ...Component<any>) -> QueryBundle<() -> (A, B, C, D, E, F, G, H)>),
query: ((World) -> Query<nil>)
& (<A>(World, Component<A>) -> Query<A>)
& (<A, B>(World, Component<A>, Component<B>) -> Query<A, B>)
& (<A, B, C>(World, Component<A>, Component<B>, Component<C>) -> Query<A, B, C>)
& (<A, B, C, D>(World, Component<A>, Component<B>, Component<C>, Component<D>) -> Query<A, B, C, D>)
& (<A, B, C, D, E>(World, Component<A>, Component<B>, Component<C>, Component<D>, Component<E>) -> Query<A, B, C, D, E>)
& (<A, B, C, D, E, F>(World, Component<A>, Component<B>, Component<C>, Component<D>, Component<E>, Component<F>) -> Query<A, B, C, D, E, F>)
& (<A, B, C, D, E, F, G>(
World,
Component<A>,
Component<B>,
Component<C>,
Component<D>,
Component<E>,
Component<F>,
Component<G>
) -> Query<A, B, C, D, E, F, G>)
& (<A, B, C, D, E, F, G, H>(
World,
Component<A>,
Component<B>,
Component<C>,
Component<D>,
Component<E>,
Component<F>,
Component<G>,
Component<H>,
...Component<any>
) -> Query<A, B, C, D, E, F, G, H>),
}
export type Record = {
@ -3696,12 +3706,12 @@ local function world_new(DEBUG: boolean?)
end
local function world_component(world: world): i53
max_component_id += 1
if max_component_id > HI_COMPONENT_ID then
if max_component_id + 1 > HI_COMPONENT_ID then
-- IDs are partitioned into ranges because component IDs are not nominal,
-- so it needs to error when IDs intersect into the entity range.
error("Too many components, consider using world:entity() instead to create components.")
end
max_component_id += 1
world.max_component_id = max_component_id
world_add(world, max_component_id, EcsComponent)
@ -3758,7 +3768,8 @@ local function world_new(DEBUG: boolean?)
end
end
local function DEBUG_ID_IS_INVALID_PAIR(id: i53)
local function DEBUG_ID_IS_INVALID(id: number)
if ECS_IS_PAIR(id) then
if ECS_ID_IS_WILDCARD(id) then
error([[
You tried to pass in a wildcard pair. This is strictly
@ -3767,6 +3778,14 @@ local function world_new(DEBUG: boolean?)
targets to remove and use jecs.bulk_remove.
]], 2)
end
local first = ecs_pair_first(world, id)
local second = ecs_pair_second(world, id)
assert(world:contains(first), `The first element of the pair is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`)
assert(world:contains(second), `The second element of the pair is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`)
else
assert(world:contains(id), `The component in your parameters is invalid because it is not alive in the entity index. You might be holding onto an outdated handle or may have forward declared ids via jecs.component() and jecs.tag(). In the latter case, ensure that their calls precede jecs.world() or otherwise they will not register correctly`)
end
end
-- NOTE(marcus): I have to save the old function and overriding the
@ -3784,21 +3803,21 @@ local function world_new(DEBUG: boolean?)
local function world_remove_checked(world: world, entity: i53, id: i53)
DEBUG_IS_DELETING_ENTITY(entity)
DEBUG_IS_INVALID_ENTITY(entity)
DEBUG_ID_IS_INVALID_PAIR(id)
DEBUG_ID_IS_INVALID(id)
world_remove(world, entity, id)
end
local function world_add_checked(world: world, entity: i53, id: i53)
DEBUG_IS_DELETING_ENTITY(entity)
DEBUG_IS_INVALID_ENTITY(entity)
DEBUG_ID_IS_INVALID_PAIR(id)
DEBUG_ID_IS_INVALID(id)
world_add(world, entity, id)
end
local function world_set_checked(world: world, entity: i53, id: i53, value: any)
DEBUG_IS_DELETING_ENTITY(entity)
DEBUG_IS_INVALID_ENTITY(entity)
DEBUG_ID_IS_INVALID_PAIR(id)
DEBUG_ID_IS_INVALID(id)
world_set(world, entity, id, value)
end
@ -3875,7 +3894,98 @@ local function ecs_entity_record(world: world, entity: i53)
return entity_index_try_get(world.entity_index, entity)
end
local function entity_index_ensure(entity_index: entityindex, e: i53)
local eindex_sparse_array = entity_index.sparse_array
local eindex_dense_array = entity_index.dense_array
local index = ECS_ID(e)
local alive_count = entity_index.alive_count
local r = eindex_sparse_array[index]
if r then
local dense = r.dense
if dense == 0 then
alive_count += 1
entity_index.alive_count = alive_count
r.dense = alive_count
eindex_dense_array[alive_count] = e
return e
end
-- If dense > 0, check if there's an existing entity at that position
local existing_entity = eindex_dense_array[dense]
if existing_entity and existing_entity ~= e then
alive_count += 1
entity_index.alive_count = alive_count
r.dense = alive_count
eindex_dense_array[alive_count] = e
return e
end
return e
else
local max_id = entity_index.max_id
if index > max_id then
for i = max_id + 1, index - 1 do
if not eindex_sparse_array[i] then
-- NOTE(marcus): We have to do this check to see if
-- they exist first because world:range() may have
-- pre-populated some slots already.
end
eindex_sparse_array[i] = { dense = 0 } :: record
end
entity_index.max_id = index
end
alive_count += 1
entity_index.alive_count = alive_count
eindex_dense_array[alive_count] = e
r = { dense = alive_count } :: record
eindex_sparse_array[index] = r
return e
end
end
local function new(world: world)
local e = ENTITY_INDEX_NEW_ID(world.entity_index)
return e
end
local function new_low_id(world: world)
local entity_index = world.entity_index
local e = 0
if world.max_component_id < HI_COMPONENT_ID then
while true do
world.max_component_id += 1
e = world.max_component_id
if not (entity_index_try_get_any(entity_index, e) ~= nil and e <= HI_COMPONENT_ID) then
break
end
end
end
if e == 0 or e >= HI_COMPONENT_ID then
e = ENTITY_INDEX_NEW_ID(entity_index)
else
entity_index_ensure(entity_index, e)
end
return e
end
local function new_w_id(world: world, id: i53)
local e = ENTITY_INDEX_NEW_ID(world.entity_index)
world.add(world, e, id)
return e
end
return {
new = new,
new_w_id = new_w_id,
new_low_id = new_low_id,
--- Create the world
world = world_new :: (boolean?) -> World,
World = {
@ -3892,7 +4002,7 @@ return {
--- OnAdd Hook to detect added components (see more how_to/110_hooks.luau)
OnAdd = (EcsOnAdd :: any) :: Component<<T>(entity: Entity, id: Id<T>, data: T) -> ()>,
--- OnRemove Hook to detect removed components (see more how_to/110_hooks.luau)
OnRemove = (EcsOnRemove :: any) :: Component<<T>(entity: Entity, id: Id<T>) -> ()>,
OnRemove = (EcsOnRemove :: any) :: Component<<T>(entity: Entity, id: Id<T>, delete: boolean?) -> ()>,
--- OnChange Hook to detect mutations (see more how_to/110_hooks.luau)
OnChange = (EcsOnChange :: any) :: Component<<T>(entity: Entity, id: Id<T>, data: T) -> ()>,
--- Relationship to define the parent of an entity
@ -3959,6 +4069,7 @@ return {
entity_index_try_get_any = entity_index_try_get_any :: (EntityIndex, Entity) -> Record,
entity_index_is_alive = entity_index_is_alive :: (EntityIndex, Entity) -> boolean,
entity_index_new_id = ENTITY_INDEX_NEW_ID :: (EntityIndex) -> Entity,
entity_index_ensure = entity_index_ensure,
Query = Query,
@ -3971,7 +4082,6 @@ return {
find_observers = find_observers :: (World, Component, Component) -> { Observer },
-- Inwards facing API for testing
ECS_ID = ECS_ENTITY_T_LO :: (Entity) -> number,
ECS_GENERATION_INC = ECS_GENERATION_INC :: (Entity) -> Entity,
ECS_GENERATION = ECS_GENERATION :: (Entity) -> number,

View file

@ -1,6 +1,6 @@
[package]
name = "ukendio/jecs"
version = "0.9.0"
version = "0.10.0"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
license = "MIT"