Creating Your First Plugin
This guide walks through creating a working UrsaMU plugin from scratch. We’ll
build a notes plugin — players can save short notes, list them, and delete
them, both in-game and through the REST API.
Scaffold the Plugin
Run this from your game project root:
ursamu create plugin notes
This creates src/plugins/notes/ with four pre-wired files:
src/plugins/notes/
├── index.ts — plugin entry point
├── commands.ts — in-game +notes command
├── router.ts — REST handler for /api/v1/notes
└── db.ts — custom DBO database
The plugin is already auto-discovered. Restart the server and it loads.
File Walkthrough
db.ts — Define your data shape
Replace the generated stub with a INote type:
import { DBO } from "../../services/Database/database.ts";
export interface INote {
id: string;
author: string; // player ID
authorName: string;
text: string;
createdAt: number; // ms timestamp
}
export const notes = new DBO<INote>("server.notes");
commands.ts — In-game commands
import { addCmd } from "../../services/commands/cmdParser.ts";
import type { IUrsamuSDK } from "../../@types/UrsamuSDK.ts";
import { notes } from "./db.ts";
addCmd({
name: "+note",
pattern: /^\+note(?:\/(\S+))?\s*(.*)/i,
lock: "connected",
exec: async (u: IUrsamuSDK) => {
const sw = (u.cmd.args[0] || "").toLowerCase();
const arg = (u.cmd.args[1] || "").trim();
// +note/list
if (sw === "list") {
const mine = await notes.find({ author: u.me.id });
if (!mine.length) { u.send("You have no notes."); return; }
for (const n of mine) u.send(`[${n.id.slice(-6)}] ${n.text}`);
return;
}
// +note/delete <id>
if (sw === "delete") {
const note = await notes.queryOne({ id: arg });
if (!note) { u.send("Note not found."); return; }
if (note.author !== u.me.id) { u.send("That is not your note."); return; }
await notes.delete({ id: note.id });
u.send("Deleted.");
return;
}
// +note <text>
if (!arg) { u.send('Usage: +note <text> or +note/list or +note/delete <id>'); return; }
const note = await notes.create({
id: crypto.randomUUID(),
author: u.me.id,
authorName: u.me.name || u.me.id,
text: arg,
createdAt: Date.now(),
});
u.send(`Saved [${note.id.slice(-6)}].`);
},
});
In-game usage:
+note Remember to update the wiki
+note/list
+note/delete <id>
router.ts — REST endpoints
import { dbojs } from "../../services/Database/index.ts";
import { notes } from "./db.ts";
const H = { "Content-Type": "application/json" };
const json = (d: unknown, s = 200) =>
new Response(JSON.stringify(d), { status: s, headers: H });
export async function notesRouteHandler(
req: Request,
userId: string | null
): Promise<Response> {
if (!userId) return json({ error: "Unauthorized" }, 401);
const { pathname } = new URL(req.url);
// GET /api/v1/notes — list my notes
if (pathname === "/api/v1/notes" && req.method === "GET") {
return json(await notes.find({ author: userId }));
}
// POST /api/v1/notes — create a note
if (pathname === "/api/v1/notes" && req.method === "POST") {
const body = await req.json().catch(() => null);
const text = typeof body?.text === "string" ? body.text.trim() : "";
if (!text) return json({ error: "text is required" }, 400);
const player = await dbojs.queryOne({ id: userId });
const authorName = (player && player.data?.name) || userId;
const note = await notes.create({
id: crypto.randomUUID(),
author: userId,
authorName,
text,
createdAt: Date.now(),
});
return json(note, 201);
}
// DELETE /api/v1/notes/:id
const m = pathname.match(/^\/api\/v1\/notes\/(.+)$/);
if (m && req.method === "DELETE") {
const note = await notes.queryOne({ id: m[1] });
if (!note) return json({ error: "Not found" }, 404);
if (note.author !== userId) return json({ error: "Forbidden" }, 403);
await notes.delete({ id: note.id });
return json({ deleted: true });
}
return json({ error: "Not Found" }, 404);
}
index.ts — Wire it together
import type { IPlugin } from "../../@types/IPlugin.ts";
import { registerPluginRoute } from "../../app.ts";
import { notesRouteHandler } from "./router.ts";
import "./commands.ts";
const notesPlugin: IPlugin = {
name: "notes",
version: "1.0.0",
description: "Player notes — in-game commands and REST API",
init: async () => {
registerPluginRoute("/api/v1/notes", notesRouteHandler);
console.log("[notes] initialized");
return true;
},
remove: async () => {
console.log("[notes] removed");
},
};
export default notesPlugin;
Test It
Start (or restart) the server:
deno task server
In-game:
+note This is my first note
+note/list
+note/delete <id>
Via REST:
# Get a token first
TOKEN=$(curl -s -X POST http://localhost:4203/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"name":"Admin","password":"yourpassword"}' | jq -r .token)
# Create a note
curl -s -X POST http://localhost:4203/api/v1/notes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Hello from the API"}' | jq
# List notes
curl -s http://localhost:4203/api/v1/notes \
-H "Authorization: Bearer $TOKEN" | jq
# Delete a note
curl -s -X DELETE http://localhost:4203/api/v1/notes/<id> \
-H "Authorization: Bearer $TOKEN" | jq
Publish It
If you want to share your plugin on GitHub so others can install it with
ursamu plugin install, add a manifest file to the plugin directory:
{
"name": "notes",
"version": "1.0.0",
"description": "Player notes — in-game commands and REST API",
"ursamu": ">=1.0.0",
"author": "Your Name",
"license": "MIT",
"main": "index.ts"
}
Save it as ursamu.plugin.json. Users can then install your plugin with:
ursamu plugin install https://github.com/you/ursamu-notes
To create a standalone, publish-ready plugin project from scratch (with
deno.json, tests, .gitignore, and ursamu.plugin.json pre-generated):
ursamu create plugin notes --standalone
Next Steps
src/plugins/jobs/— a real-world example with staff permission checks and complex REST routessrc/plugins/events/— sequential IDs, RSVP capacity enforcement, event status visibility- Plugin Reference — full
IUrsamuSDKandDBO<T>API tables - Plugin Events — subscribing and emitting game events