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.
Mental model
Section titled “Mental model”A registry is a map of points; a point is a list of handlers:
- Each handler is
{ Fn, Priority, Order }.Orderis 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
firesorts 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 underxpcallwithdebug.tracebackand receives the same context table:- returns
nil(or anything butfalse) → chain continues, - returns
false→ chain stops,firereturns(false, context), - errors → a warning prints (unless
WarnOnError = false) and, unless the point isFailOpen, the chain stops with(false, context).
- returns
- 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.
Priority doctrine
Section titled “Priority doctrine”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.luaureturn 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)endFiring 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 returnend-- 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 > 0end)Off() -- gone; handlerCount("Intent.Jump") drops by oneFail-closed, and why
Section titled “Fail-closed, and why”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:
- A validator exists because the action is dangerous without it.
- A crashing validator has not validated anything.
- 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.
Fail-open points
Section titled “Fail-open points”Some points must not reject on error, and they opt out explicitly:
kernel.Hooks:definePoint("Telemetry.Event", { FailOpen = true }) -- logging must never block gameplayThe framework uses FailOpen in two places, both deliberate:
- Observational points —
Net.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 hooks —
Inventory.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.
API reference
Section titled “API reference”| 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.
Hook points fired by the framework
Section titled “Hook points fired by the framework”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.
Performance
Section titled “Performance”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.
Design notes
Section titled “Design notes”- 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. firereuses 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.