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). 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
    }
  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 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.

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: "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: "Player",
NavPlacement: "World", // a built-in section, or a new name for your own
Zones: [],
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. Custom compiled Blazor components are not supported, only these described pages.

Instead of asking operators to copy your DLL into plugins/, ship it through the package manager as a managed package. A managed 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 plugins/<id>/ folder name
version: "1.0.0"
authors: [You]
description: "What it does"
kind: managed
binaries:
min_server_version: ">=1.1" # 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
sha256: <64-hex SHA-256>

File names are plain names with no folders; they go straight into plugins/<id>/. 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.

The installer then checks every file’s checksum, refuses a package built for a newer contract version than the server provides, and puts the files in plugins/<id>/. The plugin loads the next time the server starts; it is not loaded into the running game. Uninstalling removes plugins/<id>/, and unloads the plugin first if it only contributes commands, functions and hooks.

The boot-time MCP and LSP plugins are independently optional and disabled by default. Their authentication and rate limiting belong to the host. See Remote MCP and LSP Analysis for setup, editor usage, isolation and resource limits. Local validation and remaining real-network checks are documented there.