Skip to content

Writing Plugins

SharpMUSH can be extended at three layers. Picking the right one is most of the work, so start here rather than at the code.

LayerWhat it isReach for it when
Softcode packagesYAML + MUSHcode, installed at runtimeThe extension is game policy — permissions, formatting, capture rules — that a game owner should be able to read, fork and edit live.
C# pluginsA compiled net11.0 assembly, loaded at runtimeYou need real C# — new storage, services, performance-sensitive logic, .NET library integration — but it should not live in the core engine.
In-tree engineSharpMUSH.Implementation, compiled inYou are changing always-on core behaviour. Means recompiling the server.

The guiding split is mechanism versus policy. C# ships mechanism — primitives, storage, wiring. Softcode owns policy — the rules a particular game chooses.

A plugin is an ordinary .NET assembly dropped into the server’s plugins/ directory (or distributed by the package manager as a plugin package). Authoring is identical to writing in-tree engine code — the same [SharpCommand] and [SharpFunction] attributes, discovered by the same source generator.

Beyond commands and functions, a plugin may also contribute:

  • DI services, registered into the host container before it is built
  • Database migrations, applied once each and tracked by id
  • Engine flags, seeded into the world alongside the built-ins
  • NATS bridge subscriptions, forwarding your own subjects to SignalR groups
  • Extension hooks — the compiled-C# analog of softcode @hook

You implement an interface; the host wires it. There is no registration call to remember.

  1. Scaffold it.

    Terminal window
    dotnet new install SharpMUSH.Templates
    dotnet new sharpmush-plugin -n MyPlugin

    Or start from the extension starter repository, which carries a working example of all three layers side by side — keep the folder you want and delete the rest.

  2. Check the project file. A plugin targets net11.0, turns on dynamic loading, references SharpMUSH.Library without copying it into the plugin’s output (so the server’s own copy is used), and runs the SharpMUSH source generator as an analyzer:

    <Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <Nullable>enable</Nullable>
    <EnableDynamicLoading>true</EnableDynamicLoading>
    </PropertyGroup>
    <ItemGroup>
    <ProjectReference Include="path/to/SharpMUSH.Library/SharpMUSH.Library.csproj">
    <Private>false</Private>
    <ExcludeAssets>runtime</ExcludeAssets>
    </ProjectReference>
    <ProjectReference Include="path/to/SharpMUSH.Implementation.Generated/SharpMUSH.Implementation.Generated.csproj"
    OutputItemType="Analyzer"
    ReferenceOutputAssembly="false" />
    </ItemGroup>
    </Project>

    The generator turns your [SharpCommand] and [SharpFunction] methods into command and function tables inside your own assembly, exactly as it does for the engine.

  3. Write the entry type. Exactly one type per assembly is marked [SharpPlugin]. Deriving from PluginBase inherits the default command and function discovery, so you override only identity:

    using SharpMUSH.Library.Attributes;
    using SharpMUSH.Library.Plugins;
    [SharpPlugin]
    public sealed class MyPlugin : PluginBase
    {
    public override string Id => "myplugin";
    public override string Version => "1.0.0";
    }
  4. Add commands and functions. Exactly as in-tree code — resolve engine services at call time from parser.ServiceProvider:

    [SharpCommand(Name = "+PING", MinArgs = 0, MaxArgs = 1)]
    public static async ValueTask<Option<CallState>> Ping(
    IMUSHCodeParser parser, SharpCommandAttribute _)
    {
    var mediator = parser.ServiceProvider.GetRequiredService<IMediator>();
    var notify = parser.ServiceProvider.GetRequiredService<INotifyService>();
    var executor = await parser.CurrentState.KnownExecutorObject(mediator);
    await notify.Notify(executor, "Pong from the plugin!", executor);
    return new CallState("Pong from the plugin!");
    }

    Plugin commands register as system commands, so +PING lands in the command trie and abbreviates (+pi) like any built-in.

  5. Add the manifest. A plugin.json beside the built DLL drives discovery order and compatibility:

    {
    "id": "myplugin",
    "version": "1.0.0",
    "dependencies": [],
    "priority": 0,
    "minServerVersion": null
    }

    name and description are optional labels for the Plugins tab, entry names the plugin’s DLL when its folder holds more than one, and sharedAssemblies lists server assemblies the plugin needs to share with the server.

  6. Build and deploy.

    Terminal window
    dotnet build MyPlugin.csproj -c Release

    Drop the DLL and its plugin.json into the server’s plugins/ directory, either at the top level (plugins/MyPlugin.dll) or in a per-plugin subfolder (plugins/myplugin/MyPlugin.dll, with plugin.json beside it). On boot the server discovers, orders, loads and registers it.

Load order is resolved from declared dependencies: list another plugin’s id in your dependencies to guarantee you load after it. Cycles are detected and skipped. priority breaks ties between plugins with no dependency relationship, lower first.

A plugin command or function whose name collides with an existing engine entry — or with an earlier-loaded plugin — is logged and skipped. The existing definition wins.

A failing plugin is logged and skipped. It never aborts server boot.

The scene system is the reference case, because it deliberately spans two layers:

  • As a C# plugin, it contributes the wizard @SCENE command, the OOC command, the scene…() functions, the scene storage, the SCENE_ROOM flag, and its realtime bridge leg. That is the mechanism — store a pose, broadcast an event, gate the command.
  • As a softcode package, it ships the policy: the hook that captures poses, the +scene/* player verbs, temp-room orchestration, and the permission rules — all editable by the game owner. That is when a pose is captured and who may start a scene.

The same scene test suite passes whether it runs in-tree or as a loaded plugin.

The package names the plugin it calls (plugins: [scene] in its package.yaml), and so do the packages built on it, such as scene-verse, scene-safety, radio-scene, highlights and timeline. Without the Scene plugin running none of them installs, and turning the plugin off uninstalls them. The portal shows scene pages, widgets and links only while the game has the Scene System on, and asks nothing of api/scenes or /hubs/scene otherwise; a request to a route no running plugin serves answers 404.

A softcode package that calls your plugin’s commands or functions should list the plugin under plugins: (see Plugins a package calls). The package manager then refuses it while the plugin is not running, and removes it when an administrator turns the plugin off, instead of leaving commands that call names that are not there.

Contributing services, flags, migrations and live updates

Section titled “Contributing services, flags, migrations and live updates”

The [SharpPlugin] type may also implement any of these interfaces from SharpMUSH.Library.Plugins. Implement only the ones you need; the server finds them when it loads the plugin.

using Microsoft.Extensions.DependencyInjection;
using SharpMUSH.Library.Plugins;
[SharpPlugin]
public sealed class MyPlugin : PluginBase,
IServiceRegistrar, IFlagSource, IMigrationSource, IBridgeSubscriptionSource
{
public override string Id => "myplugin";
// Services, added to the server's container before it is built, so the engine can use them
// from the start. In commands, still resolve services at call time from parser.ServiceProvider.
public void RegisterServices(IServiceCollection services)
=> services.AddSingleton<MyPluginService>();
// Flags, created alongside the built-in flags. Safe to run again: they are matched by name.
public IEnumerable<PluginFlag> Flags =>
[
new PluginFlag(
Name: "MYFLAG", Symbol: "y", Aliases: [],
SetPermissions: ["wizard"], UnsetPermissions: ["wizard"],
TypeRestrictions: ["ROOM", "PLAYER", "EXIT", "THING"])
];
// Database migration steps, run after the built-in ones. Each runs once, recorded by its Id;
// changing the Id runs it again.
public IEnumerable<LightningMigrationStep> LightningSteps =>
[
new LightningMigrationStep("myplugin:seed_v1", accessor => ValueTask.CompletedTask)
];
// A NATS subscription forwarded to SignalR groups, run beside the built-in ones. The parameters
// are plain objects so the contract needs no SignalR reference; cast them.
public async Task RunAsync(object natsConnection, object hubContext, CancellationToken ct)
{
var nats = (NATS.Client.Core.NatsConnection)natsConnection;
await foreach (var msg in nats.SubscribeAsync<MyMessage>("game.myplugin.*", cancellationToken: ct))
{
// forward to a SignalR group...
}
}
}
  • A plugin flag can be queried as soon as the server has started.
  • A subscription should loop until ct is cancelled. One that throws is logged and stopped without affecting the others.

Hooks let a plugin step into command execution and react to connections and objects coming and going. They work alongside softcode @hook and never replace it, and a server with no hook plugins runs exactly as before.

[SharpPlugin]
public sealed class MyPlugin : PluginBase,
ICommandInterceptor, IConnectionHook, IObjectLifecycleHook
{
public override string Id => "myplugin";
// Runs beside softcode @hook/before. Returning false vetoes the command.
public ValueTask<bool> BeforeAsync(IMUSHCodeParser parser, string command)
=> ValueTask.FromResult(!command.StartsWith("@danger", StringComparison.OrdinalIgnoreCase));
// Returning a result instead of null replaces the built-in command.
public ValueTask<Option<CallState>?> TryOverrideAsync(IMUSHCodeParser parser, string command)
=> ValueTask.FromResult<Option<CallState>?>(null);
// Sees the finished command; its result is ignored.
public ValueTask AfterAsync(IMUSHCodeParser parser, string command) => ValueTask.CompletedTask;
// Connections. OnConnectAsync is also available.
public ValueTask OnLoginAsync(long handle, DBRef player) => ValueTask.CompletedTask;
public ValueTask OnDisconnectAsync(long handle, DBRef? player) => ValueTask.CompletedTask;
// Objects. OnCreatedAsync fires beside the softcode OBJECT`CREATE event; OnDestroyingAsync fires
// before the object is destroyed, while it can still be read.
public ValueTask OnCreatedAsync(DBRef obj, DBRef creator) => ValueTask.CompletedTask;
public ValueTask OnDestroyingAsync(DBRef obj) => ValueTask.CompletedTask;
}
  • Every callback has an empty default, so override only the ones you want.
  • A veto works like a softcode IGNORE that returns false: the command body is skipped and the after hooks still run. An override works like a softcode OVERRIDE.
  • A hook that throws is logged and never stops the command.
  • Hooks run in plugin load order, and the first plugin to veto or override wins.

A plugin can add controllers, SignalR hubs and endpoints to the server:

public sealed class Plugin : PluginBase, IServiceRegistrar, IEndpointContributor
{
public void RegisterServices(IServiceCollection services)
{
services.AddControllers().AddApplicationPart(typeof(Plugin).Assembly); // finds your [ApiController]s
services.AddSignalR(); // only if you map a hub
services.AddSingleton<MyService>();
}
// Called after the server maps its own controllers and hubs.
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapHub<MyHub>("/hubs/mine");
endpoints.MapGet("/api/myplugin/ping", () => "pong");
}
}

Keep your types inside the plugin. Your data types, service interfaces and messages belong in the plugin assembly, not in a shared assembly the server or portal references: the server can’t be compiled against a plugin it loads at runtime. The only meeting points are the interfaces above, which name no plugin type, and what goes over the wire as JSON. For a push to the browser, send a payload over your hub and let the client define its own matching type.

Use a plain Hub, not Hub<TClient>. SignalR’s typed client can’t refer to a plugin’s types (it would stop the plugin from being unloadable), so use IHubContext<MyHub> with SendAsync("MethodName", payload).

A plugin can add pages and menu entries to the portal without shipping any browser code, by describing a portal application and serving its schema and data from its own controller:

public sealed class Plugin : PluginBase, IServiceRegistrar, IEndpointContributor, IApplicationSource
{
public IEnumerable<RegisteredApplication> GetApplications() =>
[
new RegisteredApplication(
Slug: "myplugin",
DisplayName: "My Plugin",
Icon: null,
Kind: ApplicationKind.Page, // Page: a /apps/{slug} route; Widget: a layout tile
SchemaUrl: "/api/myplugin/schema", // served by your controller
DataUrl: "/api/myplugin/data",
SubmitRoute: null,
MinimumRole: PortalRole.Player,
NavPlacement: "World", // a built-in section, or a new name for your own
Zones: null,
Order: 100)
];
}

The application appears in the portal while the plugin is loaded, at /apps/myplugin, in the menu section you named, for players of at least MinimumRole. Nothing is saved in the database; remove the plugin and restart, and it is gone. A plugin that adds pages or endpoints is load-once. A plugin may also ship a compiled Blazor component (RenderKind: "Component", with ComponentAssemblyUrl and ComponentTypeName), which the portal loads only while the allow_browser_code option is on.

Instead of asking operators to copy your DLL into plugins/, ship it through the package manager as a plugin package. A plugin package is a folder in a git repository, like a softcode package, whose package.yaml lists the plugin’s files and their SHA-256 checksums:

package: my-plugin # also the plugin's folder name
version: "1.0.0"
authors: [You]
description: "What it does"
kind: plugin
binaries:
min_server_version: ">=2.0" # the plugin contract version your DLL was built against
files:
- file: MyPlugin.dll
sha256: <64-hex SHA-256 of MyPlugin.dll>
- file: MyPlugin.deps.json # lets the loader find your private dependencies
sha256: <64-hex SHA-256>
- file: plugin.json # your load-order metadata, with the package's id
sha256: <64-hex SHA-256>

Put a README.md beside package.yaml to say what the plugin adds and how to set it up. The Packages page shows it from the plugin’s row (see Reading a package’s README); a .zip uploaded from the portal carries it the same way, at the top of the archive.

File names are plain names with no folders. Compute each checksum from the built file (sha256sum MyPlugin.dll), and commit package.yaml and the files together. Releases are tagged like softcode packages (<package-folder>/v<version>), and the installer reads the files from that exact commit, so a moved tag can’t slip in different bytes.