Plugin Hooks & Events
UrsaMU ships two complementary event systems. Choose the one that fits your use case:
| System | Best for |
|---|---|
| GameHooks | Reacting to engine-level events (player login, movement, say, scene changes) from TypeScript plugin code |
| EventsService | Running sandbox scripts when custom events fire; in-game u.events.on/emit scripting |
Both are safe to use together in the same plugin.
GameHooks
gameHooks is a typed, singleton event bus exported from mod.ts. It fires
for 12 built-in engine events and is the correct way to react to player and
scene activity without modifying core command files.
import { gameHooks } from "jsr:@ursamu/ursamu";
// Subscribe
gameHooks.on("player:login", ({ actorId, actorName }) => {
console.log(`[GM] ${actorName} connected.`);
});
// Unsubscribe (pass the same function reference)
gameHooks.off("player:login", myHandler);
API
gameHooks.on(event, handler) // subscribe — idempotent (no double-register)
gameHooks.off(event, handler) // unsubscribe
await gameHooks.emit(event, payload) // fire all handlers; errors are caught per-handler
Error isolation — a throwing handler is logged and skipped; subsequent
handlers still run.
Fire-and-forget pattern (used throughout the engine):
gameHooks.emit("player:login", payload)
.catch((e) => console.error("[hooks] player:login error:", e));
Player Events
player:say
Fires when a player speaks in a room via say / ".
gameHooks.on("player:say", ({ actorId, actorName, roomId, message }) => {
// actorId — DB id of the speaker
// actorName — display name
// roomId — room where the message was spoken
// message — the spoken text (raw, may contain MUSH codes)
});
player:pose
Fires when a player poses/emotes via pose / : / ;.
gameHooks.on("player:pose", ({ actorId, actorName, roomId, content, isSemipose }) => {
// content — full formatted content, e.g. "Alice grins."
// isSemipose — true when ; shorthand was used (no space between name and text)
});
player:page
Fires when a player pages another player.
gameHooks.on("player:page", ({ actorId, actorName, targetId, targetName, message }) => {});
player:move
Fires when a player traverses an exit.
gameHooks.on("player:move", ({
actorId, actorName,
fromRoomId, toRoomId,
fromRoomName, toRoomName,
exitName, // e.g. "North"
}) => {});
player:login
Fires when a player connects and logs in.
gameHooks.on("player:login", ({ actorId, actorName }) => {});
player:logout
Fires when a player disconnects.
gameHooks.on("player:logout", ({ actorId, actorName }) => {});
channel:message
Fires when a player speaks on a channel.
gameHooks.on("channel:message", ({ channelName, senderId, senderName, message }) => {});
Scene Events
Scene hooks fire from the REST API (/api/v1/scenes) when scenes are created,
modified, or closed. They are the integration point for AI GM assistants and
other scene-aware plugins.
scene:created
Fires after a new scene is opened (POST /api/v1/scenes).
gameHooks.on("scene:created", ({ sceneId, sceneName, roomId, actorId, actorName, sceneType }) => {
// sceneType — "social" | "action" | "event" | "vignette" | etc.
});
scene:pose
Fires when any pose is posted to a scene — type "pose", "ooc", or "set".
gameHooks.on("scene:pose", ({ sceneId, sceneName, roomId, actorId, actorName, msg, type }) => {
// type — "pose" | "ooc" | "set"
});
scene:set
Fires additionally when a pose with type: "set" is posted (the scene
description hook). This is the primary integration point for an AI narrator —
it receives the raw description text and can respond with narration, open a new
round, or page participants.
gameHooks.on("scene:set", ({ sceneId, sceneName, roomId, actorId, actorName, description }) => {
// description — the full scene-set text
});
scene:title
Fires when a scene is renamed via PATCH /api/v1/scenes/:id.
gameHooks.on("scene:title", ({ sceneId, oldName, newName, actorId, actorName }) => {});
scene:clear
Fires when a scene is closed or finished (status transitions to "closed",
"finished", or "archived").
gameHooks.on("scene:clear", ({ sceneId, sceneName, actorId, actorName, status }) => {
// status — "closed" | "finished" | "archived"
});
WikiHooks
The wiki plugin exposes its own typed hook bus for reacting to wiki page
mutations. Import it from the wiki plugin’s public module:
import { wikiHooks } from "../../plugins/wiki/mod.ts";
Events
wiki:created
wikiHooks.on("wiki:created", ({ path, meta, body }) => {
// path — URL path of the new page, e.g. "news/announcement"
// meta — frontmatter key/value map
// body — page body markdown
});
wiki:edited
wikiHooks.on("wiki:edited", ({ path, meta, body }) => {});
wiki:deleted
wikiHooks.on("wiki:deleted", ({ path, meta }) => {});
Same on/off/emit API and error-isolation semantics as GameHooks.
EventHooks
The events plugin (calendar/RSVP system) exposes its own typed bus:
import { eventHooks } from "../../plugins/events/hooks.ts";
eventHooks.on("event:created", ({ eventId, name, startTime, createdBy }) => {});
eventHooks.on("event:updated", ({ eventId, changes }) => {});
eventHooks.on("event:deleted", ({ eventId }) => {});
eventHooks.on("event:started", ({ eventId, name }) => {});
eventHooks.on("event:ended", ({ eventId, name }) => {});
eventHooks.on("event:rsvp", ({ eventId, playerId, status }) => {});
eventHooks.on("event:cancelled", ({ eventId, name }) => {});
EventsService (pub/sub)
For running sandbox scripts in response to events, use EventsService.
This is different from GameHooks — subscribers are script strings that run
inside Web Workers with full u.* SDK access.
import { EventsService } from "../../services/Events/index.ts";
const svc = EventsService.getInstance();
// Subscribe a handler script to run when "player.connect" fires.
const subId = await svc.subscribe(
"player.connect",
`u.send("Welcome back, " + u.me.name + "!");`,
actorId, // DB object that "owns" this subscription
);
// Unsubscribe
await svc.unsubscribe(subId);
Always unsubscribe in your plugin’s remove() — orphaned subscribers
accumulate across reloads.
Emitting custom events
await svc.emit("weather.change", { newWeather: "stormy" });
// With actor context (becomes u.me in subscriber scripts)
await svc.emit("weather.change", { newWeather: "stormy" }, { id: actorId, state: {} });
In-game event scripting
// In a sandbox or system script:
const subId = await u.events.on("weather.change", `
u.send("The weather changed to: " + event.newWeather);
`);
await u.events.emit("weather.change", { newWeather: "rainy" });
Naming Conventions
Use dot-separated namespaces for custom events; built-in events use :-separated namespaces:
player:login ← built-in GameHook
scene:set ← built-in GameHook
wiki:created ← built-in WikiHook
weather.change ← custom plugin event (EventsService)
my-plugin.item.pickup ← custom plugin event (EventsService)
Full Example
A plugin that reacts to scene:set to broadcast a GM narration and also logs
player logins:
// src/plugins/gm-assist/index.ts
import type { IPlugin } from "../../@types/IPlugin.ts";
import { gameHooks } from "jsr:@ursamu/ursamu";
import { send } from "../../services/broadcast/index.ts";
import type { SceneSetEvent, SessionEvent } from "jsr:@ursamu/ursamu";
const onSceneSet = async ({ sceneId, sceneName, roomId, description }: SceneSetEvent) => {
// Narrate the scene to the room in GM voice
send([roomId], `%ch%cm[GM]%cn ${description}`, {});
console.log(`[GM] Scene "${sceneName}" set in room ${roomId}`);
};
const onLogin = ({ actorName }: SessionEvent) => {
console.log(`[GM] ${actorName} logged in.`);
};
const gmPlugin: IPlugin = {
name: "gm-assist",
version: "1.0.0",
init: () => {
gameHooks.on("scene:set", onSceneSet);
gameHooks.on("player:login", onLogin);
return true;
},
remove: () => {
gameHooks.off("scene:set", onSceneSet);
gameHooks.off("player:login", onLogin);
},
};
export default gmPlugin;