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.
| Layer | What it is | Reach for it when |
|---|---|---|
| Softcode packages | YAML + MUSHcode, installed at runtime | The extension is game policy — permissions, formatting, capture rules — that a game owner should be able to read, fork and edit live. |
| C# plugins | A compiled net11.0 assembly, loaded at runtime | You need real C# — new storage, services, performance-sensitive logic, .NET library integration — but it should not live in the core engine. |
| In-tree engine | SharpMUSH.Implementation, compiled in | You 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.
What a plugin can contribute
Section titled “What a plugin can contribute”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.
A minimal plugin
Section titled “A minimal plugin”-
Scaffold it.
Terminal window dotnet new install SharpMUSH.Templatesdotnet new sharpmush-plugin -n MyPluginOr 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.
-
Check the project file. A plugin targets
net11.0, turns on dynamic loading, referencesSharpMUSH.Librarywithout 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. -
Write the entry type. Exactly one type per assembly is marked
[SharpPlugin]. Deriving fromPluginBaseinherits 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";} -
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
+PINGlands in the command trie and abbreviates (+pi) like any built-in. -
Add the manifest. A
plugin.jsonbeside the built DLL drives discovery order and compatibility:{"id": "myplugin","version": "1.0.0","dependencies": [],"priority": 0,"minServerVersion": null} -
Build and deploy.
Terminal window dotnet build MyPlugin.csproj -c ReleaseDrop the DLL and its
plugin.jsoninto the server’splugins/directory, either at the top level (plugins/MyPlugin.dll) or in a per-plugin subfolder (plugins/myplugin/MyPlugin.dll, withplugin.jsonbeside it). On boot the server discovers, orders, loads and registers it.
Load order and collisions
Section titled “Load order and collisions”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 worked example: Scene
Section titled “The worked example: Scene”The scene system is the reference case, because it deliberately spans two layers:
- As a C# plugin, it contributes the wizard
@SCENEcommand, thescene…()functions, the scene storage, theSCENE_ROOMflag, 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
ctis cancelled. One that throws is logged and stopped without affecting the others.
Hooks: the C# @hook
Section titled “Hooks: the C# @hook”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
IGNOREthat returns false: the command body is skipped and the after hooks still run. An override works like a softcodeOVERRIDE. - 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.
Adding web endpoints
Section titled “Adding web endpoints”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).
Adding portal pages
Section titled “Adding portal pages”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.
Publishing a plugin as a package
Section titled “Publishing a plugin as a package”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 nameversion: "1.0.0"authors: [You]description: "What it does"kind: managedbinaries: 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.
Reference
Section titled “Reference”Optional analysis transports
Section titled “Optional analysis transports”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.