ECS Integration
The ECS layer binds path agents to entity-component stores without
tying the core to any ECS library. include/tess/ecs/adapter.h is the
dependency-free, always-on layer exported by tess/tess.h; concrete
adapters (EnTT under include/tess/ecs/entt/, Flecs under
include/tess/ecs/flecs/, or a game's own) implement its concepts and reuse
its components, batch scratch,
occupancy index, and tick pipeline.
The seam is "agents in deterministic order in, state write-back out".
Request submission, tickets, retry budgets, and exactly-once result
application stay inside the PathAgentState lifecycle
(sim/path_agent.h); adapters copy agent state into a batch, let the
tess pipeline mutate it, and mirror it back. They never re-implement the
lifecycle.
Public Surface
EntityHandleis the opaque, ECS-agnostic entity identity: a 64-bit value withis_null()and equality, null by default (kNullEntityHandle). Adapters pack their native id (including generation/version bits) into it losslessly; tess never interprets it.EntityHandleAdapter<A>requiresA::entity_typeplus noexceptto_handle(entity)/to_entity(handle)conversions that map the ECS's null entity tokNullEntityHandleand back.PositionAdapter<A, Entity>reads and writes a game-defined position component as tile coordinates:position(entity) -> Coord3andset_position(entity, coord). Games own their position representation.PathAgentSource<S>fills aPathAgentBatchviacollect(batch) -> PathAgentCollectInfoin a deterministic order of the source's choosing. The order must be stable across storage-packing history and replays: sort by a monotonic spawn id (AgentId), never by native entity value or pool order.PathAgentCollectInforeports the collectedcountandpathing_dirty(true iff any goal was armed or re-armed; goal clears do not set it, since cleared agents are skipped by every processing pass).PathAgentSink<S>writes batch state back to the ECS viaapply(batch). Sinks are idempotent mirrors: exactly-once result application lives in the tess tick pipeline behind the pathing-dirty gate, never in the sink.- Shared POD components, reusable by any ECS:
AgentId(monotonic per-spawn id, never recycled -- the deterministic sort key; persist the minting counter alongside the world for replay determinism across save/load),TilePosition(ECS-visible mirror of the agent's current tile, written by sinks),PathGoal(pathing input; presence means "want to be there"),PathState(the fullPathAgentStatelifecycle as one component -- deliberately not decomposed, because its fields form one invariant unit games must never mutate directly), andOffBoard(tag for parked agents that hold no tile, claim no occupancy, and are excluded from collection until placed back). PathAgentBatchis the per-tick SoA scratch:reserve,clear,push(handle, agent),size, and lockstepagents()/handles()spans. Reserve once; clear-and-refill per tick is allocation-free once warm.TileOccupancyIndexis the injective tile-to-entity occupancy map, the ECS-side mirror of a bool occupancy field:reserve(load factor at most 0.5),insert(tile, entity)(refuses, without mutating, a tile already mapped to a different entity or a null entity -- uniqueness is structural),erase(tile)(returns the erased handle; backward-shift deletion, no tombstones),move(from, to, entity)(the movement-commit hot path, debug-asserted, never rehashes),entity_at(tile),size, andclear. Steady state performs no allocation; growth beyond the reserved capacity rehashes as a setup-time event. Box/radius queries and per-chunk buckets are outside this index's scope: an open-addressing table answers area queries only by probing every coordinate in the box, which is not a useful spatial query, andentity_atis the primitive consumers need.advance_path_agents_with_index<World, ClassOrTag, OccupancyTag, ReservationTag>(world, batch, runtime, index, max_steps, dirty_mask)runsadvance_path_agents_with_movementover the batch with the commit observer keepingindexsynchronized: every successful commit moves the committing agent's mapping, failed validations touch neither the world nor the index, and arrivals need no extra work because the arrival step itself was a commit.tick_ecs_unit_path_agents<World, ClassOrTag, OccupancyTag, ReservationTag>(state, world, source, sink, batch, runtime, index, options, dirty_mask, graph)is the ECS-agnostic full tick:source.collect, dirty-gated path processing (the exactly-once seam, identical to thetick_*drivers), index-synchronized movement, thensink.apply.tick_ecs_path_agentsprovides the weighted movement-class form (<World, Class, MaxCost, ...>).
EnTT Adapter
include/tess/ecs/entt/entt_adapter.h compiles only when the consumer
defines TESS_ENABLE_ENTT and includes <entt/entity/registry.hpp>
first (an #error on ENTT_VERSION enforces the order); it ships in the
header file set regardless and is inert without the macro. tess core
never provides EnTT -- see docs/dependencies.md for the pin and the
two-gate build policy.
EnttHandleAdapterconvertsentt::entity<->EntityHandlelosslessly, with the null mapping as an explicit special case (entt's null zero-extends to a value that is notkNullEntityHandle).EnttTilePositionAdapteris the defaultPositionAdapter: it reads and writes the sharedTilePositioncomponent. Games with their own position component pass their own adapter type toEnttPathAgentSink.EnttAgentEntry(agent id + entity) andEnttPathAgentContext(tick state, batch, sorted-entry scratch, and the monotonicnext_agent_idcounter;reserveonce) are the persistent per-agent-system state.EnttPathAgentSourcecollects on-board agents (PathState+TilePosition+AgentId, excludingOffBoard) in ascending AgentId order -- entries are sorted first, then the batch is filled strictly in sorted order so batch index i and entry i stay one agent. Collection reconcilesPathGoalinto the lifecycle: a present goal differing from the armed one arms it (reported aspathing_dirty); an absent goal clears an armed lifecycle. AnUnreachableagent with an unchangedPathGoalstays terminal because the lifecycle retains the failed goal. Retry exhaustion reaches that phase only when the caller explicitly selectsBlockedAgentExhaustionPolicy::MarkUnreachable; the default leaves itBlockedfor caller-owned recovery.EnttPathAgentSink(templated on aPositionAdapter) mirrors batch state back:PathStatestored unconditionally, positions through the adapter, andPathGoalconsumed on arrival so the arrival-cleared agent does not re-arm and oscillate; a goal retained after an Unreachable failure sits inert until the game changes it.- Lifecycle intents are the only sanctioned mutation paths (raw
registry.destroyon an on-board agent leaks a permanently blocked tile):spawn_entt_path_agent(claims field + index; refuses unresolvable or occupied tiles by returningentt::null),spawn_entt_path_agent_off_board(parked from birth),despawn_entt_path_agent(releases the tile unless parked, then destroys),teleport_entt_path_agent(occupancy-checked relocation; lifecycle resets to Idle; the retainedPathGoalre-arms from the new position at the next collect -- teleporting onto the goal arrives at the next processed tick),park_entt_path_agent/place_entt_path_agent(the board-edge pair behindOffBoard), andset_entt_path_agent_goal/clear_entt_path_agent_goal(component writes reconciled by the next collect). Intents accept an optional dirty mask mirroringcommit_movement_intent. tick_entt_unit_path_agents/tick_entt_path_agentsare thin instantiations of the generictick_ecs_*pipeline with the concrete source/sink -- there is one pipeline, not two. The context'stick_stateowns the sim clock: drive these from at most one place per frame.- Every tick driver and lifecycle intent accepts a trailing optional
DeltaCollector*: ticks stamp the collector withbegin_tickand record each committed step through the same observer that keeps the occupancy index synchronized; intents record their kind exactly when they succeed (refusals mutate nothing and record nothing; a parked despawn records nothing because parking already released the tile). Entity-delta completeness holds exactly for this surface -- the legacy span drivers bypass recording by construction.
Flecs Adapter
include/tess/ecs/flecs/flecs_adapter.h is independently gated by
TESS_ENABLE_FLECS and requires the consumer to include <flecs.h> first.
It requires Flecs 4.1.5 or newer and compiles to nothing when disabled. Core
and ordinary consumers never acquire Flecs.
The public types mirror the EnTT surface: FlecsHandleAdapter,
FlecsTilePositionAdapter, FlecsAgentEntry, FlecsPathAgentContext,
FlecsPathAgentSource, and FlecsPathAgentSink. Lifecycle operations are
spawn_flecs_path_agent, spawn_flecs_path_agent_off_board,
despawn_flecs_path_agent, teleport_flecs_path_agent,
park_flecs_path_agent, place_flecs_path_agent,
set_flecs_path_agent_goal, and clear_flecs_path_agent_goal. Drivers are
tick_flecs_unit_path_agents and the weighted tick_flecs_path_agents
overloads. All pass through the same generic pipeline and uphold the same
occupancy, result-application, and render-delta invariants.
Flecs-specific constraints are explicit:
- Native
flecs::entity_tis a 64-bit identity with generation information; conversion copies every representable live ID and maps Flecs' zero invalid ID to tess null. The all-ones value is tess's null sentinel and is excluded from the adapter domain; debug builds assert if it is presented as a live Flecs ID. FlecsPathAgentContextowns the typed query. It must be constructed with the world and outlive every source/sink call, but be destroyed before the world. Sources, sinks, lifecycle intents that allocate IDs, and tick drivers assert that they receive that same world. Query construction is setup work; repeated query creation is not a hot-path operation.- Query iteration only collects pointers and IDs. Sorting and goal reads occur after iteration; state and component write-back and structural goal removal occur in the later sink phase. Lifecycle structural changes are standalone intents. This avoids Flecs' locked-table assertion and preserves the TDD's safe-phase rule.
- The typed query sees parked tables, then excludes
OffBoardin its non-mutating callback. A negated Flecs builder term would avoid that callback check, but Flecs 4.1.5's fluent term builder triggers falseStackAddressEscapefindings at downstream construction sites under the required Clang analyzer gate. Entries are sorted byAgentId, never by table order or native entity ID, so archetype churn cannot affect authoritative output. - Lifecycle intents, source/sink operations, and tick drivers require Flecs immediate mode. They must not run inside a deferred or readonly scope: Flecs would queue component changes while tess applies world occupancy and index changes immediately. Debug builds assert this precondition before the shared pipeline mutates authoritative state.
- On-board spawn and place preflight occupancy-index growth before changing
the ECS, world occupancy, or
next_agent_id. An allocation failure at that boundary therefore leaves every authoritative store unchanged.
Invariants
- Index/position sync: after any pipeline entry point returns,
index.entity_at(agent.position)is the agent's handle for every on-board agent in the batch. - One-directional field mirror: a tile mapped in the index implies the occupancy field is set. The converse is a consumer-side property only -- games may set occupancy from non-agent sources the index never sees.
- Occupancy uniqueness is structural:
insertrefuses occupied tiles andcommit_movement_intentrejects occupied destinations, so the index stays injective without advisory checks. - Exactly-once application:
apply_path_agent_resultsruns only inside the dirty-gated processing step. Sinks mirror state and never re-read results.
Runtime Exclusivity
The PathRequestRuntime passed to the ECS tick must be exclusive to that
agent system. Tickets persist inside collected PathState components
across non-processing ticks and are generation-checked; any other
submitter calling clear_requests (or processing its own batch through
the same runtime) between the agent system's processed ticks stales every
Following agent's ticket -- asserted in debug builds, silently stalled
movement in release. One runtime per (world, movement class, agent
system), extending the standing one-runtime-per-(world, class) contract.
Planning Cost
World-scoped invalidation re-searches for every active agent. The raw-span tick
drivers retain routes and use PathSubmitScope::NeedsOnly, but the current ECS
drivers intentionally use runtime-owned paths and an all-agent processing pass
whenever collection reports a changed goal. They therefore do not inherit
the retained-step optimization for mixed goal churn and occupancy waiters.
Their work remains retry-bounded and correct, and the ECS benchmark family
tracks collect/apply/tick costs at 1k-100k agents. Route retention inside
PathAgentBatch is a future optimization, not a current contract.