ECS Integration
The ECS layer (M10) 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 (the EnTT adapter under include/tess/ecs/entt/, 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 -- 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 deferred beyond the current pre-1.0 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, ...>) and the legacy passable/cost tag-pair form (<World, PassableTag, CostTag, 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. An Unreachable agent with an UNCHANGEDPathGoalstays terminal because the lifecycle retains the failed goal.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_agents(weighted class and legacy tag-pair forms) are 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*(M11): 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.
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.
Known Cost
Any processing tick re-paths every active agent: submit_path_agents
resubmits all agents with goals into a fresh generation. This is the
standing lifecycle behavior, not an adapter property, but ECS-scale agent
counts make it the relevant cost cliff -- one goal change among 100k
Following agents re-paths all of them on that tick. The ecs benchmark
family tracks collect/apply/tick costs at 1k-100k agents.