A small, dependency-free event-hook dispatcher for Roblox combat and ability systems.
-- before — every new ability means editing every call site
if attacker:HasAbility("Thorns") then ... end
if attacker:HasAbility("Vampiric") then ... end
if attacker:HasAbility("LastStand") then ... end
-- after — call site is written once, never touched again
Registry:Fire("OnHitDealt", { attacker = plr, dmg = 25 }, gateResolver)
-- ability code is self-contained, added independently, anywhere
Registry:Register("OnHitDealt", "Thorns", function(ctx)
-- reflect damage back
end)This is deliberately unopinionated:
- It does not know what an "ability" is, or how your game stores who
owns one. You supply a
gateResolver(gateKey, ctx) -> booleanfunction at:Fire()time that answers "should this entry run?". - It does not interpret your
ctxtable beyond one field:ctx._stop, which halts remaining entries for that fire call if set. Everything else inctx(a cancel flag, a mutable damage number, whatever your event needs) is a convention you define for your own game. - It does not touch Roblox services,
_G, or any specific data format. It's a plain Luau module with zero external dependencies.
This is a pattern extracted from a larger production combat system — the ability-specific logic (damage formulas, animations, specific ability names) was stripped out; what's left is just the dispatch mechanism.
Via Wally:
[dependencies]
HookRegistry = "ou1ck5cop3/hook-registry@0.1.0"Or copy src/init.lua directly into your project — it's a single
file with no dependencies.
Creates a new registry instance. Each instance has independent state, so you can run multiple registries (e.g. one for combat, one for UI) without them interfering.
Declares a new hook type. Calling it twice is a no-op. You can pass
initial hook names to .new() instead of calling this separately.
Registers fn against hookName.
gateKey— passed to yourgateResolverat fire time. Use"*"for entries that should always run regardless of what the resolver says (they never call the resolver at all).priority— lower numbers fire first. Defaults to50.
Removes a previously registered entry. Returns whether one was found and removed. Useful for un-equip flows or hot-reloading.
Runs every registered entry for hookName in priority order.
gateResolver(gateKey, ctx) -> booleanis called once per non-wildcard entry to decide whether it runs. This is where you plug in your own "does this player own this ability" check. Omit it and only"*"entries will fire.- Each entry runs inside
pcall— an error in one entry iswarn'd and does not stop the others. - An entry can set
ctx._stop = trueto halt the remaining chain cooperatively.
See example/example.lua for a worked example
with attacker-gated, victim-gated, and wildcard entries.
MIT — see LICENSE.