Skip to main content

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.

feces works by listening to every jecs world observer (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:
-- Storages shape (from types.luau)
-- _storages: {
--   [Component]: {
--     [LocalEntity]: {
--       action: ChangeType?,  -- types.None (set to nil) or types.Remove (component removed)
--       value:  any?,         -- the new value, if any
--     }
--   }
-- }
When a jecs observer fires:
  • world:added — the record is written with the new value. If the value is nil, action is set to types.None (numeric constant 0x1).
  • world:changed — same as added; the entry is overwritten with the latest value.
  • world:removed — the record is written with action = types.Remove (numeric constant 0x2) and no value.
Tags (components with no data) only get an 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:
  1. Iterates all storages. For every component that has at least one pending change, it loops over each entity that has a record.
  2. Resolves the lookup ID. Calls lookup() on the local entity to get its stable LookupEntity index. If the entity is not in the active group it is skipped.
  3. Checks replication tags. The entity must have either feces.replicated or pair(feces.replicated, component) set on it. If neither is present the change is skipped.
  4. Expands to a player list. Calls resolvePlayers() with whatever value is stored on the replication tag (see Player Filters).
  5. Fills the Changes table. Each player gets an entry under changes[component][player] containing three buckets:
-- Changes shape (from types.luau)
-- changes: {
--   [Component]: {
--     [Player]: {
--       value:  { [LookupEntity]: any },  -- components with a value
--       remove: { LookupEntity },         -- components that were removed
--       none:   { LookupEntity },         -- components set to nil (tag or explicit nil)
--     }
--   }
-- }
  1. Builds the Deletes table. Entities whose replicated tag 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.
  2. Clears processed entries. Each storage entry for a processed entity is set to nil (storage[entity] = nil) after it is consumed, so the next delta() call starts clean. The full storage table is never bulk-cleared — only per-entity entries are removed.
delta() returns both changes and deleted:
local changes, deleted = feces:delta()

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 plain replicated tag
  • feces.queries.pair — entities with a pair(replicated, Wildcard) tag
For each matching entity it reads the live component values directly from the archetype columns and builds an 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.
-- Applyable shape (from types.luau)
-- applyable: {
--   [Component]: {
--     [LookupEntity]: {
--       value:  any,
--       action: ChangeType?,
--     }
--   },
--   __d: { LookupEntity }  -- entities to delete
-- }

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:
local changes, deleted = feces:delta()
local packets = feces.combine(changes, deleted)

for player, packet in packets do
    MyRemote:FireClient(player, packet)
end
Internally, 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:
-- Client-side
MyRemote.OnClientEvent:Connect(function(packet)
    clientFeces:apply(packet)
end)
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) — the added hook fires immediately after the local entity is created, before any component changes are applied.
  • action == Remove — the removed hook fires, then world:remove(entity, component) is called.
  • Any non-delete entry (action == Remove, action == None, or a plain value) — the changed hook fires with the entity, component, and the resolved value (nil for None, the raw value otherwise). Note that changed fires for every component entry in the packet, including removes.
  • __d list — the deleted hook fires before world: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

Server world mutations


   _storages (per component, per entity)


   feces:delta()
        │  changes + deleted (keyed by component and player)

   feces.combine()
        │  { [Player]: Applyable }

   Send over remote (your code)


   feces:apply()  (client side)
        │  ref() maps LookupEntity → LocalEntity

   Client world updated + hooks fired
For new players joining, replace delta() + combine() with full() to send the complete current state as a single Applyable.

Build docs developers (and LLMs) love