feces works by listening to every jecs world observer (Documentation Index
Fetch the complete documentation index at: https://mintlify.com/NeonD00m/feces/llms.txt
Use this file to discover all available pages before exploring further.
added, changed, removed) for every component that exists in the world at the time feces.new() is called. Any component registered afterward is also tracked automatically, thanks to a __newindex metatable hook placed on world.component_index. This means you never need to register components with feces manually — as long as your components exist before or after construction, every mutation to them is captured.
Change Tracking with _storages
Internally, feces maintains a _storages table. It is keyed by component ID, and each entry maps local entity IDs to a record of what changed:
world:added— the record is written with the new value. If the value isnil,actionis set totypes.None(numeric constant0x1).world:changed— same as added; the entry is overwritten with the latest value.world:removed— the record is written withaction = types.Remove(numeric constant0x2) and no value.
added observer and a removed observer — the changed observer is skipped for tag components because world:changed would never fire for them. This is checked via jecs.is_tag(world, component) at registration time.
The
replicated component itself and the internal grouped component are
excluded from storage tracking — feces uses them for control logic, not data
replication.Delta Generation — feces:delta()
delta() drains _storages and turns every pending change into a player-keyed Changes table. Here is what it does step by step:
- Iterates all storages. For every component that has at least one pending change, it loops over each entity that has a record.
- Resolves the lookup ID. Calls
lookup()on the local entity to get its stableLookupEntityindex. If the entity is not in the active group it is skipped. - Checks replication tags. The entity must have either
feces.replicatedorpair(feces.replicated, component)set on it. If neither is present the change is skipped. - Expands to a player list. Calls
resolvePlayers()with whatever value is stored on the replication tag (see Player Filters). - Fills the
Changestable. Each player gets an entry underchanges[component][player]containing three buckets:
- Builds the
Deletestable. Entities whosereplicatedtag was removed are stored in_deleted.delta()drains that table into a per-player list of lookup IDs to delete on the client. Each entity is cleared individually (self._deleted[player][entity] = nil) as it is processed. - Clears processed entries. Each storage entry for a processed entity is set to
nil(storage[entity] = nil) after it is consumed, so the nextdelta()call starts clean. The full storage table is never bulk-cleared — only per-entity entries are removed.
delta() returns both changes and deleted:
Full Snapshot — feces:full()
full() does not touch _storages at all. Instead it walks the two cached queries that feces creates at construction time:
feces.queries.single— entities with a plainreplicatedtagfeces.queries.pair— entities with apair(replicated, Wildcard)tag
Applyable. Only entities whose replicated value is nil (broadcast mode — no specific player target) are included. Entities with a non-nil player filter on replicated are skipped, making full() suitable for sending a complete world snapshot to a newly connected player without leaking privately-targeted data.
Combining Packets — feces.combine()
delta() returns raw Changes and Deletes tables that are still separated by component and by player. combine() merges them into one flat Applyable per player that is ready to send over a remote:
combine() collapses all three change buckets (value, remove, none) into a single { [LookupEntity]: { value, action } } table keyed by player, and folds the delete list into the __d key of each player’s packet. The Deletes argument is optional — if omitted, no __d entries are written.
Applying on the Client — feces:apply()
On the client you create a second feces instance (with the same component order) and call apply() with the packet you received:
apply() walks every component in the Applyable. For the special __d key it processes entity deletions; for every other key it processes component changes. The hooks fire in this order for each entry:
- Entity is new (
ref()had no existing local entity for this lookup ID) — theaddedhook fires immediately after the local entity is created, before any component changes are applied. action == Remove— theremovedhook fires, thenworld:remove(entity, component)is called.- Any non-delete entry (
action == Remove,action == None, or a plain value) — thechangedhook fires with the entity, component, and the resolved value (nilforNone, the raw value otherwise). Note thatchangedfires for every component entry in the packet, including removes. __dlist — thedeletedhook fires beforeworld:delete(entity)is called for each entity in the delete list.
apply() fires hooks to notify your code of changes but does not call
world:set itself — your changed hook is responsible for applying values
to the client world when needed.Data Flow Overview
delta() + combine() with full() to send the complete current state as a single Applyable.