One hook per action
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.
Status: Accepted · 2026-10-03 · charted by #2188
A system that others extend through gamemode hooks has to pick a shape for them, and once addons
listen to a hook's name and arguments, changing the shape breaks them. The codebase had no shape:
about fifty notification hooks were one per event, about four took an action argument, and the
first spec to design its hooks deliberately
(limelight-development/cityrp2-contrib#75)
chose one RoomWallpaperChanged(ply, room, action) for every change. How to write the hooks
themselves is in hooks.md; this records the shape.
The decision
- Each action is a pair: a veto hook,
Can<Verb><Object>, before it, and a lifecycle hook,<Object><Verb-ed>, after it is applied. A veto hook exists only for actions a player initiates and can be refused; every action has a lifecycle hook. - One veto hook per distinct permission rule, even when the rule governs several actions.
- Causes of the same outcome share a hook through a
causeargument. Different outcomes are different hooks. - Lifecycle hooks fire after the change, with the actor first (
nilwhen no player caused it), then the subject, as it was when it was removed.
Rejected alternatives
One changed hook with an action argument
RoomWallpaperChanged(ply, room, action) gives a listener one registration that sees everything,
but every listener interested in one action filters on the argument, and a new action reaches
listeners that never expected it. In the codebase, the shape had almost no use: of the four
hooks that took one, three had no listener that read the argument, and the fourth's two listeners
each opened by filtering on it. A hook named for the action states what happened in its name, the
way the rest of the gamemode's hooks do.
One hook per route
Firing a hook from each route to an outcome, as restraint release does with PlayerUncuffed and
five PlayerReleasedBy* hooks plus PlayerArrested, makes every listener enumerate the routes.
animations registers seven identical handlers to stop an animation on release; restrained_search
registers two and misses the other five
(#2199), with no
error to show for it. A route added later is missed by every existing listener. The cause
argument keeps the route available to a listener that cares, without making the outcome depend on
listing them.
A separate "about to happen" notification
A pre-action hook that is not a veto invites listeners to act before the action is certain:
phonebook's PlayerChangeTeam listener deletes a player's listings even when a later callback
refuses the team change. The veto hook is the before side, and it only answers.
Consequences
- Existing hooks keep their shapes. They convert when their system is next reworked, as restraint release is tracked to (#2202).
- A system with many actions declares many hooks, and a listener that wants all of them registers several. That cost is visible in the listener; a missed route is not.