Skip to content

Signals - #52

Draft
warquys wants to merge 14 commits into
outfox:mainfrom
warquys:feat/component-signals
Draft

warquys wants to merge 14 commits into
outfox:mainfrom
warquys:feat/component-signals

Conversation

@warquys

@warquys warquys commented Aug 22, 2026

Copy link
Copy Markdown

Add On method returning a Signale. On as the same signature than Add. Meaning you can specify if you whant to handle relation.

// This an exemple, avoid lambda you can't remove the handler
world.On<Position>().Added   += (EntityRef e, ref Position p) => ...;
world.On<Position>().Removed += (EntityRef e, in  Position p) => ...;

Signale are contained in a Dictionary with for key the type of the compoent tarrget. Then a list do the job to raise every matching (base on the Expression).

World lock are by default on signal to avoid raising other signale will handling one. This also allow to pass a EntityRef.

Open question:

  • Should the Expression follow a patern ? Or give a new Signale each time? Currently the way of storing Expression kind a bad. For exemple: 2 differente Expression matching a same case, it will be raised depending base on the first call of On.
  • Should orphan Signals be added back to the World? Or should we only count alive signal and remove clean of the GC?
    For new we only count alive signal. Orphan Signal get remove of the list of Signal to free up space (like GC imply).
  • Should the Exception expose a method to allow for continuation of the operation? Or finishing the operation should not be an option and it should be done whatever.
    Actually Exception are raise and there is now way to fix up/continue the current Action except retrying.

What is done to do:

  • There is no incorrect information in it. However, I didn't like the way Claude wrote it. I did not rewrites theme. I make a lot of spelling mistakes, and it would take me some time to correct them. So, since the methods are still likely to change, the documentation will be done at the end.

Summary by cubic

Introduces Component Signals: structural add/remove/despawn now raise events. Previously Add/Remove/Despawn were silent; now Added fires after storage, Removed fires just before removal (including batch, spawn/despawn, relations/links), and handler failures surface as SignalException.

  • API: World.On<T>(Match) returns a memoized-per-expression Signal<T> exposing Added and Removed. Handlers receive an EntityRef. RemoveCause reports Removed, Despawned, or TargetDespawned. SignalException wraps handler faults; deferred handler work that fails is flagged with Deferred = true.
  • Semantics: Wildcards fire once per stored expression. Replace-in-place stays silent. Tolerated no-op removals stay silent. Spawn waves emit Added. Despawn emits Removed for all components and for relations whose targets die.
  • Dispatch model: Holds a World lock during handler invocation; handler-triggered structural changes are deferred and applied after dispatch. Catch-up is guarded to prevent recursive drain/stack overflows. Unsubscribing or registering signals inside handlers is supported.
  • Performance: Zero-cost guard when no subscribers; type-level probe when a type has a materialized but idle signal; batch dispatch rents buffers; Archetype-level memoization avoids repeated despawn checks; per-entity paths only lock when needed.
  • GC and lifecycle: World.GC drops signals with no subscribers; storing Signal<T> leads to stale orphans. Disposal clears signals and subscription counts.
  • Required migration: Do not store Signal<T>. Always subscribe via World.On<T>() and unsubscribe with the same call site. If you need to separate observer faults from caller errors, catch SignalException.

Written for commit e1d487b. Summary will update on new commits.

Review in cubic

warquys added 10 commits August 20, 2026 04:38
Add `On` method returning a Signale. `On` as the same signature than `Add`. Meaning you can specify if you whant to handle relation.

// This an exemple, avoid lambda you can't remove the handler
world.On<Position>().Added   += (EntityRef e, ref Position p) => ...;
world.On<Position>().Removed += (EntityRef e, in  Position p) => ...;

Signale are contained in a Dictionary with for key the type of the compoent tarrget. Then a list do the job to raise every matching (base on the Expression).

World lock are by default on signal to avoid raising other signale will handling one.

Open question:
- Should the Expression follow a patern ? Or give a new `Signale` each time? Currently the way of storing Expression kind a bad. For 2 differente Expression matching a same case, it will be raised depending base on the first call of `On`.

fix: prevent stack overflow when bulk deferred operations signal
Open question, should the Expcetion expose a method to allow for continutation of the operation? Or finishing the operation should not be an option and it should be donne whatever.
Open question: Should orphan Signals be added back to the World? Or should we only count alive signal and remove clean of the GC?
NB: I call this Event because most ECS use the term of Event, where it's a simple Signal.
@warquys
warquys marked this pull request as draft August 22, 2026 00:30

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 20 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/fennecs/World.API.cs">

<violation number="1" location="src/fennecs/World.API.cs:327">
P2: When a World has no active signal subscribers, `GC()` skips orphan-signal cleanup entirely. Remove the subscriber guard so GC also removes Signals with no handlers; otherwise repeated transient `On<T>()` registrations can retain the signal registry indefinitely.</violation>
</file>

<file name="src/fennecs/World.cs">

<violation number="1" location="src/fennecs/World.cs:131">
P1: When the despawned entity is only a relation target, this condition misses signals on entities that reference it. `Aspect.DespawnDependencies` then dispatches `TargetDespawned` without a World lock, so a handler such as `e.Add<Position>()` can move the relating entity before the pending relation migration and leave the relation pointing at the dead target. Keep the lock around every despawn that can emit dependency signals.</violation>
</file>

<file name="src/fennecs/Aspect.CRUD.cs">

<violation number="1" location="src/fennecs/Aspect.CRUD.cs:191">
P2: When a batch removes a wildcard expression, narrower Signals can be silently skipped. The fast guard matches only the Signal expression against the batch pattern, so `On<T>().Removed` does not fire for `Batch.Remove<T>(Match.Any)`; make the preflight match in both directions, or route wildcard removals through the signalling path.</violation>
</file>

<file name="src/fennecs/World.Deferred.cs">

<violation number="1" location="src/fennecs/World.Deferred.cs:52">
P2: When a Signal handler mutates another World that is already locked, this branch treats its queued operation as non-signal. Propagate signal origin across World boundaries so catch-up failures still surface as `SignalException`.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread src/fennecs/World.cs
Comment thread src/fennecs/World.API.cs Outdated
Comment thread src/fennecs/Aspect.CRUD.cs
Comment thread src/fennecs.benchmarks/ECS/SignalBenchmarks.cs
Comment thread src/fennecs/World.Deferred.cs
Comment thread src/fennecs/World.Signals.cs
Comment thread src/fennecs.benchmarks/Program.cs
Comment thread doc/docs/Basic/Entities/EntityRef.md Outdated
Comment thread doc/docs/Basic/Entities/EntityRef.md
@warquys

warquys commented Aug 22, 2026

Copy link
Copy Markdown
Author

This doesn't provide advanced component tracking; it's just enough to handle major changes. It’s useful when you want to use a physics engine or networking that doesn't integrate seamlessly with ECS.
Otherwise, I would have been forced to use an extension method to do the same job and mark the entity with a flag. Then destroy the entity properly in an other querry.

I would have liked to use a flag system that notifies mark entity and lets the user query them, However, that introduces too many complications. Such as what happens if the user doesn't query, and the need to store the components and entity before destruction. Meaning an other world just holding data before final change.

One issue with Signal is the fact of not knowing what the other handler can do. Like doing a loop of removing adding back component. Or adding a component that an other handler already added.

All of this to say, i look for the simplest solution to feed the lake of notification. There is no problem to fully refuse this draft. I will keep the work for me.

@warquys

warquys commented Aug 22, 2026

Copy link
Copy Markdown
Author

And i added an entry to EntityRef, can be remove it's only add this page because i find the need to mention it in the doc of Signal.

GC_Drops_Signals_Once_Everyone_Has_Unsubscribed test will need a rewrite
base on the anwser of the open question (GC one). Currently it's the
only whay to check. Else it will imply relfection.
I run the benchmarks longueur, this to check if the last change on the benchmark change somthing. I use those metric for the doc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant