marked/hammer

A set of utilities for Jecs

CI CD License: MIT Wally Pesde

A set of utilities for Jecs

Installation

Hammer is available on pesde @ marked/hammer and Wally @ mark-marks/hammer.

Usage

All utilities that require a Jecs world to function are exposed via a constructor pattern.
For instance, to build a ref:

local ref = hammer.ref(world)

This is the easiest solution for passing a world that doesn't sacrifice readability internally and externally or bind the developer to a Jecs version that hammer is currently using.

collect

A collect collects all arguments fired through the given signal, and exposes an iterator for them.
Its purpose is to interface with signals in ECS code, which ideally should run every frame in a loop.

For instance, take Roblox's RemoteEvents:

local pings = hammer.collect(events.ping)
local function system()
    for _, player, ping in pings do
        events.ping:FireClient(player, "pong!")
    end
end

command_buffer

A command_buffer lets you buffer world commands in order to prevent iterator invalidation.
Iterator invalidation refers to an iterator (e.g. world:query(Component)) becoming unusable due to changes in the underlying data.

To prevent this, command buffers can be used to delay world operations to the end of the current frame:

local command_buffer = hammer.command_buffer(world)

while true do
    step_systems()
    command_buffer.flush()
end

-- Inside a system:
command_buffer.add(entity, component) -- This runs after all of the systems run; no data changes while things are running

ref

A ref allows for storing and getting entities via some form of reference.
This is particularly useful for situations where you reconcile entities into your world from a foreign place, e.g. from across a networking boundary.

local ref = hammer.ref(world)

for id in net.new_entities.iter() do
    local entity = ref(`foreign-{id}`) -- A new entity that can be tracked via a foreign id
end

Refs by default create a new entity if the given value doesn't reference any stored one. In case you want to see if a reference exists, you can find one:

local entity[: Entity?] = ref.find(`my-key`)

Refs can also be deleted. All functions used to a fetch a reference also return a cleanup function:

local entity, destroy_reference = ref(`my-key`)
destroy_reference() -- `entity` still persists in the world, but `my-key` doesn't refer to it anymore.

Refs are automatically cached by world. ref(world) will have the same underlying references as ref(world).
In case you need an unique reference store, you can omit the cache via ref(world, true).

tracker

A tracker keeps a history of all components passed to it, and how to get to their current state in the least amount of commands.
They're great for replicating world state across a networking barrier, as you're able to easily get diffed snapshots and apply them.

local tracker = hammer.tracker(world, ComponentA, ComponentB)

world:set(entity_a, ComponentA, 50)
world:add(entity_b, ComponentB)

-- Says how to give `entity_a` `ComponentA` with the value of `50` and give `entity_b` `ComponentB`.
-- `state()` always tracks from when the tracker was first created.
local state = tracker.state()

-- Same as the above, but now this sets the origin for the next taken snapshot!
local snapshot = tracker.snapshot()

world:remove(entity_b, ComponentB)

-- This now only says to remove `ComponentB` from `entity_b`.
local snapshot_b = tracker.snapshot()

Trackers simplify the state internally. Removals remove all prior commands pertaining to the entity and component pair, adds remove all prior removals, etc.

Trackers are optimized under the hood with lookup tables for arrays, to allow for a constant time operation to check for whether it has a member or not. It can lead to worse memory usage, but makes it faster overall.