CityRP Documentation Repository

One hook per action

Last updated

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 cause argument. Different outcomes are different hooks.
  • Lifecycle hooks fire after the change, with the actor first (nil when 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.