UrsaMU Plugin Development
Overview
Plugins are the primary way to extend UrsaMU. A plugin can provide any
combination of:
- In-game commands — registered with
addCmd, available to all connected players - REST API routes — registered with
registerPluginRoute, accessible to custom frontends - A private database — a
DBO<T>collection namespaced to the plugin - Config defaults — merged into the global config on startup
Plugins are auto-discovered. Drop a folder into src/plugins/ with an
index.ts that exports a default IPlugin object and the engine loads it on
next start — no registration required.
Plugin Structure
A full plugin lives in its own subdirectory:
src/plugins/my-plugin/
├── index.ts — entry point (IPlugin, registerPluginRoute)
├── commands.ts — in-game commands (addCmd)
├── router.ts — HTTP route handler
├── db.ts — custom DBO database
└── ursamu.plugin.json — manifest (required for ursamu plugin install)
Only index.ts is required. The other files are imported from it.
Quick Scaffold
The fastest way to start is the built-in CLI scaffolder. Run from your game
project root:
ursamu create plugin my-plugin
This generates all four files pre-named and pre-wired. Restart the server and
the plugin loads automatically. You can also copy src/plugins/example/
directly — it is a fully working reference implementation.
Plugin Interface
Every plugin implements IPlugin:
import type { IPlugin } from "../../@types/IPlugin.ts";
const myPlugin: IPlugin = {
name: "my-plugin", // unique slug — used for logging and config namespacing
version: "1.0.0", // semver
description: "Does a thing",
// Optional: default config values merged into global config at startup
config: {
plugins: {
"my-plugin": {
enabled: true,
maxItems: 50,
},
},
},
// Called once at startup — register routes here, return false to abort load
init: async () => {
console.log("[my-plugin] initialized");
return true;
},
// Called when the plugin is unloaded
remove: async () => {
console.log("[my-plugin] removed");
},
};
export default myPlugin;
Adding Commands
Import addCmd and call it at module load time (in commands.ts, not inside
init()). The exec function receives a fully populated IUrsamuSDK (u).
import { addCmd } from "../../services/commands/cmdParser.ts";
import type { IUrsamuSDK } from "../../@types/UrsamuSDK.ts";
addCmd({
name: "+greet",
pattern: /^\+greet(?:\/(\S+))?\s*(.*)/i,
lock: "connected",
exec: async (u: IUrsamuSDK) => {
const sw = (u.cmd.args[0] || "").toLowerCase(); // switch after /
const arg = (u.cmd.args[1] || "").trim(); // rest of input
if (sw === "all") {
u.broadcast(`${u.me.name} greets everyone!`);
return;
}
u.send(`Hello, ${arg || "world"}!`);
},
});
Import commands.ts from index.ts to trigger registration at startup:
// index.ts
import "./commands.ts";
The IUrsamuSDK object
| Property | Description |
|---|---|
u.me |
The acting player — id, name, flags (Set), state, location |
u.here |
The current room |
u.cmd.args |
Regex capture groups from pattern |
u.send(msg) |
Send a message to the current player |
u.broadcast(msg) |
Send a message to everyone in the room |
u.db.search(query) |
Query the main object database |
u.chan.* |
Channel SDK |
u.events.* |
Pub/sub EventsService SDK |
u.auth.* |
Auth SDK (hash, setPassword) |
u.sys.* |
System SDK (disconnect, setConfig) |
u.util.* |
Utility helpers (target, stripSubs, ljust, rjust, …) |
Adding REST Routes
Register a route handler from init():
// index.ts
import { registerPluginRoute } from "../../app.ts";
import { myRouteHandler } from "./router.ts";
init: async () => {
registerPluginRoute("/api/v1/my-plugin", myRouteHandler);
return true;
},
The handler receives the raw Request and the authenticated userId (or
null if unauthenticated — JWT verification is handled by the engine before
your handler is called):
// router.ts
const JSON_HEADERS = { "Content-Type": "application/json" };
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { status, headers: JSON_HEADERS });
}
export async function myRouteHandler(
req: Request,
userId: string | null
): Promise<Response> {
if (!userId) return json({ error: "Unauthorized" }, 401);
const { pathname } = new URL(req.url);
if (pathname === "/api/v1/my-plugin" && req.method === "GET") {
return json({ ok: true });
}
return json({ error: "Not Found" }, 404);
}
All CORS headers are added automatically by the engine.
Custom Database
Create a typed DBO<T> collection in db.ts:
import { DBO } from "../../services/Database/database.ts";
export interface IMyRecord {
id: string;
author: string;
text: string;
createdAt: number;
}
export const myRecords = new DBO<IMyRecord>("server.my-plugin-records");
DBO<T> methods:
| Method | Description |
|---|---|
create(record) |
Insert a new record, returns the created object |
queryOne(query) |
Find the first match, or undefined |
find(query) |
Find all matches |
update({}, record) |
Replace a record (matches by id) |
modify(query, "$set", data) |
Partial field update |
delete(query) |
Remove matching records |
all() |
Return every record in the collection |
Configuration
Plugins declare defaults in the config property. Values are read via
getConfig:
import { getConfig } from "../../services/Config/mod.ts";
const maxItems = getConfig<number>("plugins.my-plugin.maxItems") ?? 50;
Operators override values in config/config.json under the same key path.
The Manifest File
Every plugin that will be shared or installed from GitHub must include an
ursamu.plugin.json at the plugin root. The install command reads this file to
display details and populate the local registry.
{
"name": "my-plugin",
"version": "1.0.0",
"description": "Does something useful",
"ursamu": ">=1.0.0",
"author": "Your Name",
"license": "MIT",
"main": "index.ts",
"deps": [
{ "name": "jobs", "url": "https://github.com/UrsaMU/jobs-plugin", "version": "^1.9.0" }
]
}
| Field | Required | Description |
|---|---|---|
name |
yes | Directory-safe slug — becomes the install folder name |
version |
yes | Semver string |
description |
yes | Short human-readable description |
ursamu |
yes | Semver range of compatible UrsaMU versions |
author |
no | Author name or contact |
license |
no | SPDX license identifier, e.g. "MIT" |
main |
no | Entry-point file, defaults to "index.ts" |
deps |
no | Array of transitive plugin dependencies — see below |
deps[] entries
Each entry declares a plugin this one needs at runtime. The installer
resolves the full graph before writing anything.
| Field | Required | Description |
|---|---|---|
name |
yes | Plugin slug — install folder name |
url |
yes | Git URL the installer will clone |
ref |
no | Git ref (tag, branch, commit) |
version |
no | Semver range (e.g. "^1.2.0", ">=1.0.0 <2.0.0") checked against the dep’s own manifest version |
The version field is optional and opt-in. When omitted, the dep installs
as before with no version check. When present, the installer reads the
dep’s ursamu.plugin.json after clone and aborts if its version does
not satisfy the range — or if two requesters ask for incompatible ranges.
Atomic installs
ensurePlugins (and the bulk install path used on first startup) is
fail-fast across the entire manifest. If any plugin or transitive dep
fails for any of these reasons, the whole run aborts and rolls back:
- Clone failure or rename failure
- Unsafe plugin name (path traversal, reserved characters)
- Unsafe or unsupported clone URL
- Manifest version does not satisfy a requested
version:range - Two requesters declare incompatible
version:ranges for the same dep - Malformed semver in any range or manifest version
On abort, nothing from the failed run is left on disk or in
.registry.json. Plugins installed in previous successful runs are not
touched. The installer throws a PluginInstallError (or one of its
subclasses) describing which entry failed and why.
The ursamu create plugin <name> --standalone command generates this file
automatically when scaffolding a new publishable plugin project.
Installing Community Plugins
The plugin manager handles install, update, remove, and inspect operations:
# Install from a GitHub URL
ursamu plugin install https://github.com/user/my-plugin
# Update to the latest commit
ursamu plugin update my-plugin
# List all installed plugins (with version + source)
ursamu plugin list
# Show manifest and registry details
ursamu plugin info my-plugin
# Remove a plugin
ursamu plugin remove my-plugin
The install flow:
- Clones the repo with
git clone --depth 1 - Reads
ursamu.plugin.json(warns but continues if absent) - Displays the manifest and asks for confirmation before writing anything
- Copies the plugin into
src/plugins/ - Records the source URL in
src/plugins/.registry.jsonsoupdateworks later
Use --force to skip the confirmation prompt in CI/automation:
ursamu plugin install --force https://github.com/user/my-plugin
Real Examples
The bundled plugins demonstrate every capability:
| Plugin | What it shows |
|---|---|
src/plugins/example/ |
Minimal template — commands, REST, database, config |
src/plugins/jobs/ |
Full CRUD REST API, staff permission checks, in-game commands with switches |
src/plugins/events/ |
Sequential IDs, RSVP capacity enforcement, cancelled-event visibility rules, REST + in-game commands |
Plugin Docs Index
| Page | Topic |
|---|---|
| basics.md | IPlugin interface, lifecycle, auto-discovery, file layout |
| first-plugin.md | Step-by-step walkthrough of a complete plugin |
| commands.md | addCmd reference, patterns, switches, lockfuncs |
| database.md | DBO<T> collections, queries, namespacing rules |
| configuration.md | Default config values, getConfig, env vars for secrets |
| hooks.md | gameHooks event bus, scene/wiki/event/chargen hooks, EventsService |
| dependencies.md | Sharing code between plugins, deps[] manifest entries |
| official-plugins.md | Plugin registry — channel, discord, jobs, events, bbs, wiki, mail, builder, chargen, help |
| chargen.md | Bundled chargen plugin — commands, hooks, REST API |
| events.md | Bundled events plugin — calendar, RSVPs, REST API |