Skip to content

Hooks

The HookRegistry is how the framework asks “may this happen?” — named hook points, each holding a priority-ordered chain of handlers that share one mutable context table and can cancel the action by returning false. The defining decision is fail-closed: a validator that crashes counts as a rejection. A bug in your permission logic can deny players an action; it can never grant one. Every network intent and request passes through a hook chain before its handler runs — see NetDriver.

A registry is a map of points; a point is a list of handlers:

  • Each handler is { Fn, Priority, Order }. Order is a global registration counter, so sorting is stable: ties on priority keep registration order.
  • Sorting is lazy — adding or removing a handler marks the point dirty; the next fire sorts once and takes a frozen snapshot. The snapshot is what actually runs, so a handler that unregisters (itself or another handler) mid-fire cannot make the chain skip the next validator. Mid-fire changes take effect on the next fire.
  • fire(name, context) walks the snapshot in order. Each handler runs under xpcall with debug.traceback and receives the same context table:
    • returns nil (or anything but false) → chain continues,
    • returns false → chain stops, fire returns (false, context),
    • errors → a warning prints (unless WarnOnError = false) and, unless the point is FailOpen, the chain stops with (false, context).
  • Firing a point with no handlers — including a name never mentioned anywhere — returns (true, context). Points and topics cost nothing to invent; there is no registration ceremony for your own names.

Cancellation returns the context as-is, so mutations made by earlier handlers before the cancel are visible to the caller — useful for attaching a rejection reason.

Lower numbers run first. The framework reserves the front of every chain:

Priority Who What runs there
5 Registry Validate rules Declarative argument checks (Range, OneOf, MaxLength) compiled from channel definitions — mechanical rejection before any game code sees the payload
10+ Game validators Your permission logic, cheapest checks first
50 Kit gates e.g. InteractionKit’s distance/line-of-sight/cooldown gate on Intent.Interact — game rules can pre-empt it at lower numbers without forking the kit
100 Default Handlers registered without a priority

The canonical shape — an intent chain with argument validation ahead of a state gate:

-- src/Server/Bootstrap.luau
return function(kernel)
-- Validator 1: argument validation (priority 10 runs first)
kernel.Hooks:on("Intent.UseItem", function(context)
if ItemCatalog[context.Args[1]] == nil then
return false -- unknown id: rejected, the handler never runs
end
end, 10)
-- Validator 2: state gate — server-side state, never client claims
kernel.Hooks:on("Intent.UseItem", function(context)
local LastUse = context.Session.Data.LastUse or 0
if os.clock() - LastUse < ItemCatalog[context.Args[1]].Cooldown then
return false
end
end, 20)
end

Firing a chain yourself works the same way — hooks are not networking-specific:

local Passed, Context = kernel.Hooks:fire("Trade.CanOffer", {
Seller = SellerSession,
Buyer = BuyerSession,
ItemId = ItemId,
})
if not Passed then
return
end
-- Context may have been enriched by validators (e.g. Context.Tax set by an economy rule)
applyTrade(Context)

Validators mutate the context to normalize, not just to veto. Intent.<Channel> validators may rewrite context.Args — clamp a magnitude, canonicalize a string — and the intent handler receives the mutated copy. One chain, both jobs: reject the impossible, sanitize the merely wrong.

Removal is the returned function:

local Off = kernel.Hooks:on("Intent.Jump", function(context)
return context.Session.Data.Stamina > 0
end)
Off() -- gone; handlerCount("Intent.Jump") drops by one

The xpcall around every handler is not there to keep the chain alive — it is there to convert crashes into rejections. The reasoning, from first principles:

  1. A validator exists because the action is dangerous without it.
  2. A crashing validator has not validated anything.
  3. Therefore the action must not proceed. Fail-open would mean any bug in permission logic — a nil index on a malformed payload an attacker crafted specifically to crash it — becomes permission.

The same doctrine extends past errors to absence, implemented at the Net layer: an intent or request channel that has a handler but no validator registered and is not explicitly marked Open = true rejects every payload. A missing validator is not permission either — this closes the classic unguarded-GrantCoins footgun where a handler trusts a client-supplied amount because nobody remembered to guard it. Declaring Validate rules counts as a validator; a genuinely authorization-free channel (a payload-less ready-up) is acknowledged explicitly with Open = true. The channel logs a one-time [NetDriver] ... fail-closed warning when it rejects for this reason, and Net:auditValidators() sweeps for handler-bearing channels missing both. Details on NetDriver.

Some points must not reject on error, and they opt out explicitly:

kernel.Hooks:definePoint("Telemetry.Event", { FailOpen = true }) -- logging must never block gameplay

The framework uses FailOpen in two places, both deliberate:

  • Observational pointsNet.RateLimited, Net.FloodDetected, AntiExploit.MovementViolation. These fire after the framework already acted (the message was dropped, the strike recorded); handlers attach policy like kicking. A crashing policy handler should not suppress the others.
  • Kit gate hooksInventory.CanEquip, Inventory.CanUse, Craft.CanCraft, Companion.CanSummon, Melee.CanHit, Party.CanInvite, Weapon.CanDamage. These carry optional game rules (tournament locks, team checks) layered on top of transport chains that are already fail-closed. The kit’s default answer is “allowed”; a crashing custom rule degrades to the default instead of bricking equipping game-wide. The pattern and each kit’s gates are covered in the kits overview.

kernel:securityAudit() flags fail-open points guarding channels, so the escape hatch stays visible.

Member Description
HookRegistry.new(options?) → HookRegistry options = { WarnOnError: boolean? }, default true. The kernel creates one at boot as kernel.Hooks
hooks:definePoint(name, options?) options = { FailOpen: boolean? }. Points default fail-closed; calling with no options just ensures the point exists
hooks:on(name, fn, priority?) → () -> () Registers fn(context) → boolean?. Default priority 100; ties keep registration order. Returns the removal function
hooks:fire(name, context?) → (boolean, context) Runs the chain against context (default {}). true = every handler passed; false = cancelled or a handler errored on a fail-closed point. The context is returned in both cases
hooks:handlerCount(name) → number Registered handlers on a point — this is what NetDriver’s unguarded-channel check reads
hooks:listPoints() → { { Name, FailOpen, Handlers } } Snapshot of every point, for audits and debug tooling

Handler contract: fn(context: { [any]: any }) → boolean?. Return false to cancel; any other return (including nil and true) continues the chain.

The full table lives in the hooks & bus topics reference; the shape to internalize:

Point Mode Role
Intent.<Channel> / Request.<Channel> fail-closed Before every intent/request handler; context { Name, Player, Session, Args, Seq? }
Intent.BusPublish.<Topic> fail-closed Client-to-server bus publishes, after whitelist and rate limit
Session.Start / Session.End observational Runs synchronously before the matching bus topic — seed per-player state here
Net.RateLimited / Net.FloodDetected / AntiExploit.MovementViolation fail-open Policy attachment after enforcement
Kit gates (Inventory.CanEquip, Craft.CanCraft, …) fail-open Optional game rules over already-guarded transport

Kit channels are ordinary intents (Intent.WK_Fire, Intent.SK_Cast, Intent.Checkpoint), so prepending your own rules at a lower priority number works everywhere without forking anything.

A three-validator chain costs 148ns per fire (6.6M ops/s) — measured with one mutating handler, one conditional veto, and one pass-through, the realistic intent shape. The full intent security pipeline (rate limit + session check + 3 validators) stays well under a microsecond per packet. Numbers and methodology in Benchmarks. The lazy sort means registration churn costs one table.sort on the next fire, not per registration; steady-state fires never sort.

  • Hooks ask permission; the Bus announces facts. A hook handler can cancel and mutate; a Bus subscriber can do neither. If nothing could ever veto, publish a topic instead — chains imply a question.
  • Handler crashes on fail-closed points still print a traceback warning by default (WarnOnError). Specs pass { WarnOnError = false } to keep expected-failure tests quiet; production code should leave it on — a chain silently rejecting honest players because of a validator bug is the failure you most want to see in the logs.
  • fire reuses the caller’s context table — it is not copied. Do not fire the same table concurrently from two threads, and treat the post-fire table as the validators’ output, not your original input.