Customization Guide

This guide covers the real extension points for UrsaMU — the patterns you
actually use to add commands, store per-object data, build plugins, and connect
to outside services.

Text Formatting

UrsaMU uses MUSH-style substitution codes for in-game text. They work in any
string you pass to u.send(), u.broadcast(), or stored in a help file.

Color codes

Code Color / Effect
%cr Red
%cg Green
%cy Yellow
%cb Blue
%cm Magenta
%cw White
%ch Bold / bright
%cn Reset to normal

Example:

u.send("%ch%crERROR:%cn You can't do that.");
// → bold red "ERROR:" followed by normal text

Other substitutions

Code Expands to
%n Actor’s name
%r Newline
%t Tab
%b Space

Note: In the old Customization guide this was listed as %r = Red. That
was wrong — %r is a newline, not red. Red is %cr.


Custom Commands

Register new in-game commands with addCmd(). Call it at module level — it
runs once when your file is imported at server startup.

import { addCmd } from "jsr:@ursamu/ursamu";
import type { IUrsamuSDK } from "jsr:@ursamu/ursamu";

addCmd({
  name: "+roll",
  pattern: /^\+roll(?:\s+(\d+)d(\d+))?/i,
  lock: "connected",
  exec: async (u: IUrsamuSDK) => {
    const count = parseInt(u.cmd.args[0] || "1");
    const sides = parseInt(u.cmd.args[1] || "6");
    const results: number[] = [];
    for (let i = 0; i < count; i++) {
      results.push(Math.ceil(Math.random() * sides));
    }
    const total = results.reduce((a, b) => a + b, 0);
    u.send(`%ch+ROLL:%cn ${results.join(", ")} = %ch${total}%cn`);
  },
  help: "+roll [<count>d<sides>]\nRolls dice. Default: 1d6.",
});

ICmd fields

Field Type Required Description
name string Yes Display name for help listings
pattern string | RegExp Yes Matched against raw input; capture groups become u.cmd.args
lock string No Lock expression evaluated before exec (default: none)
exec (u: IUrsamuSDK) => void | Promise<void> Yes Command handler
help string No Help text shown by the help command
hidden boolean No If true, omit from help listings
category string No Groups commands under a category in help

Overriding built-in commands

Register a command with the same name — the last one registered wins.

addCmd({
  name: "look",
  pattern: /^(?:look|l)(?:\s+(.+))?$/i,
  lock: "connected",
  exec: async (u: IUrsamuSDK) => {
    const arg = (u.cmd.args[0] || "").trim();
    if (arg) {
      const target = await u.util.target(u.me, arg);
      if (!target) return u.send("I don't see that here.");
      return u.send(target.state.desc as string || "You see nothing special.");
    }
    u.send(u.here.state.desc as string || "You see nothing special.");
  },
});

Switches

Commands can accept /switch syntax — @set/quiet object=flag:

addCmd({
  name: "@myset",
  pattern: /^@myset(?:\/([\w]+))?\s+(.*)/i,
  lock: "connected",
  exec: (u: IUrsamuSDK) => {
    const sw  = (u.cmd.args[0] || "").toLowerCase();
    const arg = u.cmd.args[1] || "";
    if (sw === "quiet") {
      // suppress output
    }
    u.send(`Set: ${arg}`);
  },
});

Custom Attributes

All game objects have a state field (Record<string, unknown>) for storing
arbitrary data. Use u.db.modify() to write and obj.state to read.

Storing a value

// Set "gold" on the actor
await u.db.modify(u.me.id, "state", { ...u.me.state, gold: 100 });

Reading a value

const gold = u.me.state.gold as number || 0;
u.send(`%chGOLD:%cn You have ${gold} gold.`);

Storing on another object

const target = await u.util.target(u.me, u.cmd.args[0]);
if (!target) return u.send("Not found.");
await u.db.modify(target.id, "state", { ...target.state, desc: u.cmd.args[1] });
u.send("Description set.");

Per-object stats example

// +stats command
addCmd({
  name: "+stats",
  pattern: /^\+stats(?:\s+(.+))?/i,
  lock: "connected",
  exec: async (u: IUrsamuSDK) => {
    const who = u.cmd.args[0]
      ? await u.util.target(u.me, u.cmd.args[0])
      : u.me;
    if (!who) return u.send("Not found.");
    const str = who.state.str as number || 10;
    const dex = who.state.dex as number || 10;
    const con = who.state.con as number || 10;
    u.send(
      `%ch${who.name}%cn — STR: %ch${str}%cn  DEX: %ch${dex}%cn  CON: %ch${con}%cn`
    );
  },
});

Plugins

A plugin is a TypeScript module with an optional init() function. Plugins
are imported by your game’s src/main.ts at startup.

Plugin structure

src/plugins/my-plugin/
├── index.ts        ← IPlugin export (optional init hook)
└── commands.ts     ← addCmd() registrations

index.ts

import type { IPlugin } from "jsr:@ursamu/ursamu";
import "./commands.ts";   // importing triggers addCmd() registrations

export const plugin: IPlugin = {
  name:        "my-plugin",
  version:     "1.0.0",
  description: "Does cool things.",
  init: async () => {
    // One-time setup runs before the first player connects.
    // Fetch external data, seed DB records, register scheduled jobs, etc.
    console.log("[my-plugin] Ready.");
    return true;
  },
  remove: () => {
    // Optional cleanup if the plugin is ever unloaded.
  },
};

Loading a plugin in src/main.ts

import { plugin as myPlugin } from "./plugins/my-plugin/index.ts";

if (myPlugin.init) await myPlugin.init();

IPlugin fields

Field Type Required Description
name string Yes Unique plugin identifier
version string Yes Semver version string
description string No Human-readable description
init () => boolean | Promise<boolean> No Called once at startup; return false to abort
remove () => void | Promise<void> No Called when plugin is unloaded

REST Routes

Register custom HTTP endpoints with registerPluginRoute(). Routes are
mounted alongside the built-in /api/v1/ routes.

import { registerPluginRoute } from "jsr:@ursamu/ursamu";

registerPluginRoute("/api/v1/my-plugin", async (req, userId) => {
  if (req.method !== "GET") {
    return new Response("Method Not Allowed", { status: 405 });
  }
  return Response.json({ message: "Hello from my plugin!", userId });
});

The handler receives:

  • req: Request — the Deno Request object
  • userId: string | null — the authenticated player’s DB ID, or null if
    the request has no valid JWT

Auth tip: Check userId and return 401 for endpoints that require
authentication. The JWT is decoded for you — you don’t need to re-verify it.

Returning data from the DB

registerPluginRoute("/api/v1/leaderboard", async (_req, _userId) => {
  const players = await dbojs.queryAll((o) => o.flags.has("player"));
  const ranked = players
    .map((p) => ({ name: p.data?.name, gold: p.data?.state?.gold ?? 0 }))
    .sort((a, b) => (b.gold as number) - (a.gold as number))
    .slice(0, 10);
  return Response.json(ranked);
});

External Integrations

Fetching data in a command

Deno has native fetch() — no extra packages needed.

addCmd({
  name: "+weather",
  pattern: /^\+weather\s+(.+)/i,
  lock: "connected",
  exec: async (u: IUrsamuSDK) => {
    const city = encodeURIComponent(u.cmd.args[0]);
    try {
      const resp = await fetch(`https://wttr.in/${city}?format=3`);
      const text = await resp.text();
      u.send(`%chWEATHER:%cn ${text.trim()}`);
    } catch {
      u.send("%chWEATHER:%cn Could not retrieve weather.");
    }
  },
  help: "+weather <city>\nFetches current weather for a city.",
});

Fetching in init()

If your plugin needs to pre-fetch data or connect to an external service, do
it in init() so it’s ready before players arrive:

let motdText = "Welcome!";

export const plugin: IPlugin = {
  name: "motd-fetcher",
  version: "1.0.0",
  init: async () => {
    try {
      const resp = await fetch("https://your-api.example.com/motd");
      motdText = (await resp.json()).message;
    } catch {
      // keep default
    }
    return true;
  },
};

// In a command handler:
addCmd({
  name: "+motd",
  pattern: /^\+motd$/i,
  lock: "connected",
  exec: (u: IUrsamuSDK) => { u.send(motdText); },
});

Discord bridge

For a Discord bridge plugin, use the built-in u.chan SDK methods alongside
a Discord WebSocket client in your plugin’s init(). See the Discord plugin
in src/plugins/discord/ as a reference implementation.