Hooks: exposing extension points
Last updated
May be stale
This page was last updated ago, on 3 October 2026. Check what it says against the code before relying on it.
A system that other code should be able to change or observe without patching it exposes extension points: a veto hook before an action, which decides whether a player may take it, and a lifecycle hook after it, which announces that it happened. The terms are defined in the glossary, and the shape (one hook per action, paired before and after) is recorded in ADR 0003.
Ship a system's extension points with the system. Retrofitting them later means finding every route to the action first, and the routes nobody found are where listeners silently miss events (#2199).
This page is for new extension points. The hooks the gamemode already fires predate it and keep their names and shapes; convert one when you are already editing its system, not as a sweep.
Veto hooks
The default rule is a GM: method
The system's own answer lives in a GM: method named after the hook, and the system asks through
hook.Run. GMod runs every hook.Add callback first and only reaches the GM: method when they
all abstain (footgun #3),
which is what makes the default rule overridable. A rule written inline in the plugin with a
hook.Run in front of it can only be overridden after the fact, by a callback that has to restate
it.
- Name:
Can<Verb><Object>, specific enough not to collide (CanEditRoomWallpaper, notCanEdit). No plugin prefix. - One veto hook per distinct permission rule. When one rule governs several actions (editing covers creating, updating and removing a room), it is one hook. Three hooks with identical defaults let an addon override one and leave the other two open.
- Where: in the plugin's own realm file (
sv_init.lua, orsh_init.luafor a shared hook), so a plugin that does not load takes its default and its callers with it.GMis only set while the gamemode loads, so open the block withlocal GM = GM or GAMEMODEto survive a hot reload of the file. - Returns: always a boolean, plus a player-facing reason string when it refuses. Because the
default always answers,
nilnever reaches the caller. - Answers only. A veto hook or default that changes state does so even when a later callback refuses, and runs again for every predicted or repeated check. React to the action on its lifecycle hook, which fires once it is applied.
local GM = GM or GAMEMODE
--- Whether a player may pin a notice to a board. Staff and the kill switch are decided before this
--- runs, by the caller.
--- @hook
--- @param ply Player The player pinning the notice.
--- @param board Entity The board being pinned to.
--- @return boolean allowed Whether the player may pin to the board.
--- @return string? reason Shown to the player when refused.
function GM:CanPinNotice(ply, board)
if board:CPPIGetOwner() ~= ply then
return false, "You can only pin notices to your own board."
end
return true
end
A hook.Add callback returns nil to abstain, false, reason to refuse, or true to allow. Any
non-nil return is final.
Guards run before the hook
A guard is a check that must hold whatever any addon says, so it runs in the caller before
hook.Run, never inside the default rule, where a callback's answer skips it. For a
player-permission hook, the caller evaluates in this order:
- Invalid input: an invalid player or subject is refused.
- Staff: allowed. No addon can lock staff out of a system they have to be able to fix.
- Kill switch: if the system has one and it is on, refused.
- The hook, which reaches the default rule when every callback abstains.
Staff come before the kill switch so they can still act while it is on. A system leaves the staff guard out only when its spec says why.
--- @return boolean allowed
--- @return string? reason
function PLUGIN:CanPin(ply, board)
if not IsValid(ply) or not IsValid(board) then return false, "That board no longer exists." end
if ply:IsAdmin() then return true end
if self.killSwitch:GetBool() then return false, "Notice boards are closed right now." end
return hook.Run("CanPinNotice", ply, board)
end
The caller reads both values, local ok, reason = self:CanPin(ply, board), and refuses on not ok.
Realm
The server is authoritative: it runs the guards and the hook for every action, whatever a client
decided. A veto hook may also run clientside, to predict an action or grey out UI, the way stock
PlayerNoClip answers on the client to predict movement. That client answer is a prediction, so
the server re-checks.
A hook can be shared only when every input to its guards and default rule is networked. Put the
default and the guards in sh_init.lua, and keep callbacks giving the same answer on both realms.
A default that reads server-only state (the database, door ownership tables) keeps the hook
server-only, with the server sending the outcome and the reason to the client. A prediction desync
is accepted only as a known exception, tracked and fixed as soon as possible.
Lifecycle hooks
- One hook per action, fired after the action is applied, so a listener sees the new state. Every action gets one, including those no player initiates (a property lost, a staff clear). Only player-initiated actions that can be refused also get a veto hook.
- Name:
<Object><Verb-ed>, the object first so a system's hooks sort together:RoomWallpaperCreated,RoomWallpaperUpdated,RoomWallpaperRemoved. - One hook per outcome, with a
causeargument. Different causes of the same outcome (removed by its owner, by property loss, by a staff clear) fire the same hook. A listener that cares whether the room is gone then cannot miss a route, which is howrestrained_searchcame to clear its state on two of the seven ways a restraint comes off (#2199). Different outcomes, like create and remove, are different hooks. - Arguments: the actor first,
nilwhen no player caused it; then the subject; then thecauseand anything else. A removed subject is passed as it was. The veto hook takes the same arguments, minus anything known only afterwards. - A default too: an empty
GM:method, which carries the hook's documentation.
--- Called after a notice is taken off its board.
--- @hook
--- @param actor Player? The player who removed it, or nil when no player did.
--- @param notice table The notice as it was before removal.
--- @param cause string `"removed"` (by its author), `"expired"` or `"staff_clear"`.
function GM:NoticeRemoved(actor, notice, cause) end
A value that changes on its own, a single networked field rather than a system object with several
fields or actions, takes no lifecycle hook of its own: use the network var's :SetHook() or
:AutoHook(), which pass (ent, new, old, predicted) (see networking.md).
Documenting a hook
Document the hook on its GM: method: the description, --- @hook, and the usual @param and
@return. A lifecycle hook lists every cause value in that parameter's description. See
luacats.md for the tag.
Testing a veto hook
A new veto hook ships with GLuaTest cases that pin the wiring, not just the rule:
- a
hook.Addcallback allows a player the default rule refuses; - a
hook.Addcallback cannot refuse a guarded case (staff are still allowed); - the default rule, called directly as
GAMEMODE:CanX(...), gives the expected answer for each case, apart from thehook.Runwiring.
Listening to a hook
A plugin listens through PLUGIN:AddHook(event[, func]), which owns the registration and replaces
rather than stacks on a reload. See plugins.md.