Skip to content

World: modernize ECS usage with Flecs idioms#193

Open
Segfaultd wants to merge 8 commits into
developfrom
refactor/ecs-modernization
Open

World: modernize ECS usage with Flecs idioms#193
Segfaultd wants to merge 8 commits into
developfrom
refactor/ecs-modernization

Conversation

@Segfaultd
Copy link
Copy Markdown
Member

@Segfaultd Segfaultd commented May 12, 2026

Summary

Sweep through the ECS layer replacing legacy patterns with idiomatic Flecs v4 features. No behavioral changes intended; all 93 RunFrameworkTests pass.

Tags, relations, observers

  • Convert PendingRemoval and RemovedOnResourceReload from [[maybe_unused]] uint8_t stubs to true zero-size tags.
  • Introduce a DependsOn relation marked Acyclic with (OnDeleteTarget, Remove); the Streamable::dependentEntities vector is gone and the (DependsOn, *) graph is walked via entity::each<DependsOn>.
  • Engine::GetEntityByGUID and ClientEngine::GetEntityByServerID are now O(1), backed by OnSet/OnRemove observer-maintained caches with reverse maps so reassignments do not leave stale forward entries.
  • The interval-based RemoveEntities system is now an OnAdd observer on PendingRemoval; streamer cleanup and e.destruct() happen the instant removal is requested.
  • TickRateRegulator no longer inherits from Transform — composition with a snapshot member — and its system uses a plain .each(...).

Custom streaming events

  • The framework's per-entity Streamable::events.spawnProc/despawnProc/updateProc/ownerUpdateProc/selfUpdateProc function pointers are replaced with custom flecs events (StreamSpawnEvent, StreamDespawnEvent, StreamUpdateEvent, StreamOwnerUpdateEvent, StreamSelfUpdateEvent). The streaming loop emits them with a {peer, targetGuid} payload; RegisterServerStreamObservers / RegisterClientStreamObservers translate them into network messages and still invoke each entity's modEvents.* callback. Mods can subscribe additional observers to the same events.
  • Streamable factory's per-entity SetupServerEmitters/SetupClientEmitters calls are replaced by OnAdd observers registered once at engine init.

Pipeline and infrastructure

  • Custom OwnershipPhase and StreamingPhase declared with flecs::Phase + depends_on; the three server systems express order through the type system instead of all crowding into PostUpdate.
  • _findAllStreamerEntities, _allStreamableEntities, and _findAllResourceEntities are built with .cached() so they stop rebuilding their match set every iteration.
  • TickRateRegulator system is tagged .multi_threaded() — gated by world.set_threads(N), no-op at N=1.
  • Active NetworkPeer is now a flecs::Singleton (NetworkPeerHandle); Engine::GetNetworkPeer() wraps the lookup. Old _networkPeer member removed.
  • defer_begin/defer_end pairs replaced with world.defer([&]{ ... }) for RAII-style scoping.
  • OwnedResource{Resource*} back-pointer component removed; callers look up the C++ Resource via ResourceManager::GetResource(entity.name()).
  • Reflection member<>() registrations lifted out of #ifdef _WIN32 so the Flecs explorer / REST API can introspect components on every platform.

Explicitly not changed, with rationale

  • Streamer::entities (per-frame mutating std::unordered_map) was not converted to a (StreamingTo, target) relation: per-pair archetype churn every spawn/despawn outweighs the structural benefit.
  • Per-entity strategy callbacks (assignOwnerProc, isVisibleProc, collectRangeExemptEntitiesProc) remain fu2::functions on the component — a singleton policy cannot express per-entity choice and a tag/observer scheme adds ceremony without a real win.

Test plan

  • cmake --build build --target RunFrameworkTests — 93/93 pass.
  • Manual smoke test on a downstream project (MafiaMP needs its modEvents consumers re-verified; the in-tree copy already cannot build on this branch for an unrelated flecs/flecs.h include-path issue).

Version impact

Touches shared ECS modules and the network-side streaming event surface — MAJOR bump per CONTRIBUTING.md. Requires matching client + server updates.

Summary by CodeRabbit

  • Refactor
    • Overhauled entity streaming and synchronization architecture using event-driven observers instead of direct callbacks.
    • Improved entity caching mechanisms for faster lookups during client-server communication.
    • Simplified entity dependency tracking with automatic lifecycle management.
    • Enhanced network peer management through centralized singleton pattern.

Review Change Stack

Segfaultd added 4 commits May 12, 2026 12:47
- Convert PendingRemoval and RemovedOnResourceReload to true zero-size
  tags (drop the placeholder uint8_t member) and add a DependsOn tag
  relation.
- Replace the dependentEntities vector on Streamable with the
  (DependsOn, *) relation, queried via flecs::entity::each<DependsOn>.
- Replace per-frame Streamer query scans in GetEntityByGUID with an
  observer-maintained guid -> entity cache on the Engine.
- Replace per-frame ServerID query scans in GetEntityByServerID with an
  observer-maintained serverID -> entity cache on ClientEngine. Switch
  CreateEntity to set<>() so OnSet fires.
- Convert the interval-based RemoveEntities system to an OnAdd observer
  triggered by the PendingRemoval tag, so streamer cleanup and entity
  destruction happen the moment removal is requested.
- Replace the unusual TickRateRegulator : Transform inheritance with
  composition (snapshot field), keeping the regulator state isolated
  from the live transform.
- Emit modified<Streamer>() in PlayerFactory so the guid cache observers
  see the populated values.
- Maintain a reverse entity -> guid map in Engine so the OnSet observer
  can drop the stale forward entry when Streamer.guid is reassigned.
- Mirror the same reverse map on ClientEngine for the serverID cache so
  ServerID.id reassignments do not leave a stale forward entry behind.
- Anchor the PendingRemoval cleanup observer on the PendingRemoval tag
  itself (with the Streamable lookup moved into the callback body) so
  the observer can no longer be triggered by adding Streamable to an
  entity that already carries PendingRemoval.
- Drop the unused ServerConfig::removeEntitiesTickInterval field; it was
  vestigial after the cleanup system became an observer.
- Convert the framework's per-entity Streamable spawn/despawn/update
  procs into custom Flecs events (StreamSpawnEvent, StreamDespawnEvent,
  StreamUpdateEvent, StreamOwnerUpdateEvent, StreamSelfUpdateEvent).
  The streaming loop emits these events with a peer/targetGuid payload;
  framework observers translate them into network messages. Per-entity
  modEvents callbacks remain as strategy hooks.
- Expose the active NetworkPeer as a singleton NetworkPeerHandle so
  systems and observers read it via world.get<>() rather than capturing
  the Engine in lambdas.
- Declare custom OwnershipPhase and StreamingPhase pipeline phases (each
  DependsOn the previous) so server systems express ordering through
  the type system instead of all crowding into PostUpdate.
- Replace Streamable factory's manual SetupServerEmitters/SetupClient-
  Emitters calls with OnAdd-driven wire-up registered at engine init.
- Cache the reused Streamer, Streamable, and ResourceReload queries
  with .cached() so we stop rebuilding their match sets every iteration.
- Convert the TickRateRegulator system from .run() with manual it.next()
  to a plain .each() callback.
- Drop the OwnedResource{Resource*} back-pointer component. Callers
  that need the C++ Resource for a given entity look it up by name via
  ResourceManager::GetResource(entity.name()).
- Mark DependsOn as Acyclic and set (OnDeleteTarget, Remove) so dangling
  pairs disappear automatically when a dependency target is destroyed.
  Previously the IsEntityVisibleToStreamerInternal traversal could carry
  stale pair references until the dependent itself was cleaned up.
- Drop the redundant Engine::_networkPeer member. The NetworkPeerHandle
  singleton is now the single source of truth; SetNetworkPeer publishes
  the value, GetNetworkPeer() reads it back.
- Replace defer_begin/defer_end pairs with world.defer([&]{ ... }) in
  PurgeAllResourceEntities, ClientEngine::OnDisconnect and
  Resource::DestroyChildEntities — auto-terminates on exceptions and is
  the v4-idiomatic form.
- Tag the TickRateRegulator system as multi_threaded(). It only mutates
  its own entities' regulator snapshot / updateInterval, no event
  emission or structural changes, so it parallelizes safely when the
  world is configured with set_threads(N).
- Lift the Transform/Frame/Streamable/Streamer/vec3/quat reflection
  member<>() registrations out of #ifdef _WIN32. The Flecs explorer
  uses them on every platform and there's no runtime cost.
- Route all NetworkPeer reads through GetNetworkPeer() instead of
  unpacking the singleton handle at every call site.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Warning

Rate limit exceeded

@Segfaultd has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 32 minutes and 7 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 922f4a84-d1fb-41bb-91be-2b866acc0c18

📥 Commits

Reviewing files that changed from the base of the PR and between 2242ace and d37252e.

📒 Files selected for processing (10)
  • code/framework/src/world/client.cpp
  • code/framework/src/world/client.h
  • code/framework/src/world/engine.cpp
  • code/framework/src/world/engine.h
  • code/framework/src/world/modules/base.hpp
  • code/framework/src/world/modules/modules_impl.cpp
  • code/framework/src/world/server.cpp
  • code/framework/src/world/types/streaming.hpp
  • code/tests/framework_ut.cpp
  • code/tests/modules/streaming_ut.h

Walkthrough

This PR refactors the streaming architecture to use Flecs observers and event emission instead of stored callbacks and direct function invocation. OwnedResource tracking is removed, new event-driven components replace stored procedures, observer-maintained caches enable efficient GUID/ServerID lookups, and server streaming systems now emit events that trigger observer-based network synchronization.

Changes

Observer-based Streaming

Layer / File(s) Summary
OwnedResource removal
code/framework/src/scripting/resource/resource.cpp, resource.h, code/tests/modules/resource_ut.h
Removes OwnedResource component from root entity creation, simplifies child entity destruction to use defer lambda, and updates test to validate entity name instead of component membership.
Event components and module registration
code/framework/src/world/modules/base.hpp
Introduces tag components (PendingRemoval, RemovedOnResourceReload, DependsOn, NetworkPeerHandle) and streaming event types (StreamEventBase with peer/targetGuid, and derived Stream*Event variants). Refactors TickRateRegulator to contain Transform snapshot instead of inheriting. Simplifies Streamable by removing base event storage and dependent-entity member. Updates Base constructor to register event components, set up DependsOn as acyclic relation with cleanup, and declare observer registration functions.
GUID and ServerID observer-based caching
code/framework/src/world/engine.cpp, engine.h, client.cpp, client.h
Implements observer-maintained lookup caches: Engine maintains _guidToEntity/_entityToGuid for Streamer components (reacting to OnSet/OnRemove with reassignment handling), and ClientEngine maintains _serverIDToEntity/_entityToServerID for ServerID components. Both GetEntityByGUID and GetEntityByServerID resolve through maps returning flecs::entity::null() when missing or not alive. Includes GetNetworkPeer() accessor returning peer from singleton.
Observer-based streaming event wiring
code/framework/src/world/modules/modules_impl.cpp
Implements server observers for ServerStreamSpawn/Despawn/SelfUpdate/Update/OwnerUpdate and client observer for ClientStreamUpdate, extracting typed event payloads, validating peer/payload, constructing/sending network messages with guid targeting, and invoking mod procedures. Replaces removed SetupServerEmitters/SetupClientEmitters callbacks.
Server streaming pipeline refactoring
code/framework/src/world/server.cpp, server.h
Introduces custom Flecs phases (OwnershipPhase, StreamingPhase) and registers server stream observers during init. Replaces polling-based RemoveEntities system with observer reacting to PendingRemoval, removing stream registrations and emitting StreamDespawnEvent before entity destruction. Moves visibility/entity systems to streamingPhase. Rewrites TickRateRegulator system as multi-threaded Flecs each() lambda updating snapshot and updateInterval. Refactors StreamEntities to emit framework events (gated by performTickUpdates) instead of calling base procedures. Updates dependent-visibility check to traverse DependsOn relations via Flecs instead of stored list. Removes removeEntitiesTickInterval from config.
Factory updates for component modification notification
code/framework/src/world/types/player.hpp, streaming.hpp
Updates PlayerFactory::SetupDefaults and SetupServer to emit modified<Streamer>() after setting guid/hardwareId, triggering observer cache updates. Updates StreamingFactory::SetupClient/SetupServer to remove explicit emitter wiring and rely on Streamable OnAdd observers registered at engine init time.

Sequence Diagram

sequenceDiagram
    participant ServerInit as ServerEngine::Init
    participant Flecs as Flecs World
    participant StreamObs as StreamObservers
    participant ServerSync as GameSyncEntity Network
    participant Client as ClientEngine

    ServerInit->>Flecs: Import Base module
    Flecs->>Flecs: Register event components<br/>(StreamSpawnEvent, etc.)
    ServerInit->>Flecs: Create phases<br/>(OwnershipPhase, StreamingPhase)
    ServerInit->>Flecs: Register server stream observers
    
    Note over ServerInit,Flecs: Entity creation triggers observer chain
    ServerInit->>+Flecs: Create entity with Streamer component
    Flecs->>Flecs: OnSet observer updates<br/>_guidToEntity cache
    Flecs->>Flecs: Streamable OnAdd observer<br/>wires mod events
    Flecs-->>-ServerInit: Entity ready
    
    Note over Flecs,Client: Streaming update loop
    Flecs->>Flecs: StreamEntities system runs<br/>in StreamingPhase
    Flecs->>Flecs: Emit StreamUpdateEvent
    Flecs->>StreamObs: Observer receives event
    StreamObs->>ServerSync: Build GameSyncEntityUpdate
    ServerSync->>Client: Send to client with<br/>ServerID + Transform
    StreamObs->>StreamObs: Invoke mod update proc
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • MafiaHub/Framework#190: Removes OwnedResource struct that was added in PR #190; both PRs modify Resource root-entity creation code path.
  • MafiaHub/Framework#178: Both PRs refactor streaming/visibility subsystem; PR #178 added Streamable.dependentEntities and visibility methods while this PR replaces them with DependsOn relations and observer-based architecture.

Suggested reviewers

  • zpl-zak

Poem

🐰 Observers dance where callbacks once stood still,
Events flow like streams down Flecs' windmill.
Dependencies bloom in relations so fine,
Cache maps keep GUID and ID in line.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'World: modernize ECS usage with Flecs idioms' is fully related to the main changes in the changeset, which systematically convert the ECS layer to use idiomatic Flecs v4 features.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/ecs-modernization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@code/framework/src/world/modules/modules_impl.cpp`:
- Around line 130-137: The code constructs
Framework::Networking::Messages::GameSyncEntityUpdate msg and only populates it
when e.try_get<Transform>() (tr) and e.try_get<ServerID>() (sid) are present,
but still calls payload->peer->Send(msg, payload->targetGuid) unconditionally;
change the logic so the send is guarded by the same condition (e.g., only call
payload->peer->Send(...) inside the if (tr && sid) block or return/skip when tr
or sid are missing) to avoid transmitting unpopulated/invalid
GameSyncEntityUpdate messages.

In `@code/framework/src/world/server.cpp`:
- Around line 121-133: The code treats a generation mismatch (t.GetGeneration()
!= reg.lastGenID), which is deliberately triggered by WakeEntity(), as a reason
to slow updates; instead treat it as a wake event and keep the
high-rate/defaultUpdateInterval. Modify the logic around t.GetGeneration(),
reg.lastGenID and decreaseRate so that a generation mismatch does NOT set
decreaseRate = true (either set decreaseRate = false or directly set
s.updateInterval = s.defaultUpdateInterval when the generations differ), then
update reg.lastGenID and proceed; ensure s.updateInterval only increases when
decreaseRate is legitimately true, not on a WakeEntity() generation bump.
- Around line 187-207: The code is checking rs[i].performTickUpdates but that
flag belongs to the streamed entity otherS, so replace uses of
rs[i].performTickUpdates with otherS.performTickUpdates and gate both the
StreamUpdateEvent and StreamOwnerUpdateEvent emission on
otherS.performTickUpdates; locate the checks around the emission blocks (the
blocks that call _world->event<Modules::Base::StreamUpdateEvent>() and
_world->event<Modules::Base::StreamOwnerUpdateEvent>() and the variable
map_it->second) and change the conditional so the entity's performTickUpdates
(otherS.performTickUpdates) controls whether the events are emitted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1b78d40f-f75c-4941-8d5d-b407bc2c135b

📥 Commits

Reviewing files that changed from the base of the PR and between e10a7a2 and 2242ace.

📒 Files selected for processing (13)
  • code/framework/src/scripting/resource/resource.cpp
  • code/framework/src/scripting/resource/resource.h
  • code/framework/src/world/client.cpp
  • code/framework/src/world/client.h
  • code/framework/src/world/engine.cpp
  • code/framework/src/world/engine.h
  • code/framework/src/world/modules/base.hpp
  • code/framework/src/world/modules/modules_impl.cpp
  • code/framework/src/world/server.cpp
  • code/framework/src/world/server.h
  • code/framework/src/world/types/player.hpp
  • code/framework/src/world/types/streaming.hpp
  • code/tests/modules/resource_ut.h
💤 Files with no reviewable changes (3)
  • code/tests/modules/resource_ut.h
  • code/framework/src/scripting/resource/resource.h
  • code/framework/src/world/server.h

Comment thread code/framework/src/world/modules/modules_impl.cpp Outdated
Comment thread code/framework/src/world/server.cpp
Comment thread code/framework/src/world/server.cpp Outdated
Segfaultd added 4 commits May 12, 2026 13:36
- modules_impl.cpp ClientStreamUpdate observer: guard the network Send
  inside the (tr && sid) check so we no longer transmit a default-
  constructed GameSyncEntityUpdate when either component is missing.
  Bug existed in the legacy SetupClientEmitters too; preserved
  faithfully during the refactor and now fixed.
- types/streaming.hpp: rewrite stale "wire up emitter procs" comment to
  describe the actual event-observer based wiring.
- world/server.cpp: note in the spawn path that the unconditional
  s[i].entities insert is intentional — observers cannot reject a spawn
  in this refactor, matching the legacy spawnProc which always returned
  true. Documents the contract for future mods.
- world/modules/base.hpp: expand the DependsOn comment to flag that
  flecs::Acyclic now asserts on cycles in debug builds where the old
  visited-set guard silently tolerated them; the runtime guard stays
  as defense in depth.
- Reorder Engine members so the guid cache maps are declared before
  _world, and add an explicit ClientEngine destructor that resets
  _world before its derived serverID maps go out of scope. Without
  this the OnRemove observers fired during ~flecs::world would touch
  freed map memory.
- Add tests/modules/streaming_ut.h covering the guid cache invariant
  across Streamer.guid reassignment and the (OnDeleteTarget, Remove)
  cleanup on the DependsOn relation. 95/95 tests pass.
Engine::WakeEntity() decrements reg.lastGenID specifically to create a
generation mismatch with t.GetGeneration(), and resets updateInterval to
the default in the same call. The TickRateRegulator system was reading
that mismatch as a reason to slow down (decreaseRate = true), which on
the next tick incremented updateInterval again and silently undid the
wake.

Flip the branch to decreaseRate = false so a wake signal keeps the
entity at the default high rate. Bug existed before this refactor;
preserved by the .run()->.each() conversion and now fixed.
The Streamable::performTickUpdates flag documents itself as controlling
"whether this entity gets to be updated continuously" — it belongs to
the entity being streamed (otherS), not to the streamer (rs[i]). The
streaming loop was reading it off the streamer for both the self-update
branch and the non-owner update branch, so a streamed entity that
opted out of continuous ticks could still be tick-updated as long as
the streamer hadn't.

Fix all three emission sites:
- Self-update: read otherS.performTickUpdates (equivalent to rs[i] here
  since e == it.entity(i), but consistent with the rest of the loop).
- Non-owner update: swap rs[i] for otherS — the real bug.
- Owner update: add the gate that the legacy code was missing entirely,
  so opted-out entities don't receive owner updates every tick either.
Sweep through the streaming-related code for the same shape that the
recent ClientStreamUpdate fix hit: try_get*<C>() returns null if the
entity lacks C, but the code dereferences the result unconditionally.

Fix each call site by null-checking before deref:
- AssignEntityOwnership: streamer-only query, so Transform/Streamable
  on the streamer entity is not guaranteed by the iteration terms.
  Without the guard, a streamer missing either component would
  crash inside the per-tick ownership system.
- UpdateEntityTransform: Streamable is optional on the client entity;
  fire modEvents.updateTransformProc only when the component is there.
- Engine::WakeEntity: was guarding only TickRateRegulator; the
  Streamable read could still null-deref if the entity carries the
  regulator without a Streamable.
- Client SetTransform / SetFrame RPCs: peer-controlled target entity
  may not carry Transform / Frame.
- Server / client GameSyncEntity* receivers: same — drop the message
  when the targeted component is missing rather than crashing.

Also move the _findAllResourceEntities query build from
ServerEngine::Init to base Engine::Init. ResourceManager calls
PurgeAllResourceEntities on both client and server; the previous code
left the query default-constructed on the client, where calling .each()
on it is undefined behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants