World: modernize ECS usage with Flecs idioms#193
Conversation
- 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.
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
WalkthroughThis 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. ChangesObserver-based Streaming
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (13)
code/framework/src/scripting/resource/resource.cppcode/framework/src/scripting/resource/resource.hcode/framework/src/world/client.cppcode/framework/src/world/client.hcode/framework/src/world/engine.cppcode/framework/src/world/engine.hcode/framework/src/world/modules/base.hppcode/framework/src/world/modules/modules_impl.cppcode/framework/src/world/server.cppcode/framework/src/world/server.hcode/framework/src/world/types/player.hppcode/framework/src/world/types/streaming.hppcode/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
- 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.
Summary
Sweep through the ECS layer replacing legacy patterns with idiomatic Flecs v4 features. No behavioral changes intended; all 93
RunFrameworkTestspass.Tags, relations, observers
PendingRemovalandRemovedOnResourceReloadfrom[[maybe_unused]] uint8_tstubs to true zero-size tags.DependsOnrelation markedAcyclicwith(OnDeleteTarget, Remove); theStreamable::dependentEntitiesvector is gone and the(DependsOn, *)graph is walked viaentity::each<DependsOn>.Engine::GetEntityByGUIDandClientEngine::GetEntityByServerIDare now O(1), backed byOnSet/OnRemoveobserver-maintained caches with reverse maps so reassignments do not leave stale forward entries.RemoveEntitiessystem is now anOnAddobserver onPendingRemoval; streamer cleanup ande.destruct()happen the instant removal is requested.TickRateRegulatorno longer inherits fromTransform— composition with asnapshotmember — and its system uses a plain.each(...).Custom streaming events
Streamable::events.spawnProc/despawnProc/updateProc/ownerUpdateProc/selfUpdateProcfunction pointers are replaced with custom flecs events (StreamSpawnEvent,StreamDespawnEvent,StreamUpdateEvent,StreamOwnerUpdateEvent,StreamSelfUpdateEvent). The streaming loop emits them with a{peer, targetGuid}payload;RegisterServerStreamObservers/RegisterClientStreamObserverstranslate them into network messages and still invoke each entity'smodEvents.*callback. Mods can subscribe additional observers to the same events.SetupServerEmitters/SetupClientEmitterscalls are replaced byOnAddobservers registered once at engine init.Pipeline and infrastructure
OwnershipPhaseandStreamingPhasedeclared withflecs::Phase+depends_on; the three server systems express order through the type system instead of all crowding intoPostUpdate._findAllStreamerEntities,_allStreamableEntities, and_findAllResourceEntitiesare built with.cached()so they stop rebuilding their match set every iteration.TickRateRegulatorsystem is tagged.multi_threaded()— gated byworld.set_threads(N), no-op atN=1.NetworkPeeris now aflecs::Singleton(NetworkPeerHandle);Engine::GetNetworkPeer()wraps the lookup. Old_networkPeermember removed.defer_begin/defer_endpairs replaced withworld.defer([&]{ ... })for RAII-style scoping.OwnedResource{Resource*}back-pointer component removed; callers look up the C++ResourceviaResourceManager::GetResource(entity.name()).member<>()registrations lifted out of#ifdef _WIN32so the Flecs explorer / REST API can introspect components on every platform.Explicitly not changed, with rationale
Streamer::entities(per-frame mutatingstd::unordered_map) was not converted to a(StreamingTo, target)relation: per-pair archetype churn every spawn/despawn outweighs the structural benefit.assignOwnerProc,isVisibleProc,collectRangeExemptEntitiesProc) remainfu2::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.modEventsconsumers re-verified; the in-tree copy already cannot build on this branch for an unrelatedflecs/flecs.hinclude-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