Skip to content

Writing a plugin

A plugin is one assembly with a public class implementing IMoongatePlugin, dropped under <root>/plugins/<bundle>/. It registers services into the host’s container before the server starts; the host resolves and starts them the same way it starts its own built-in services.

This page takes you from an empty class library to a deployed bundle. The complete worked example, samples/Moongate.Sample.Plugin/, registers a persistence entity, a Lua module and enum, a console command and a metric provider, and the test suite loads it through the real plugin loader.

Every plugin implements IMoongatePlugin from Moongate.Server.Core.Interfaces.Plugins:

public interface IMoongatePlugin
{
MoongatePluginData Metadata { get; }
void Register(Container container);
}

Metadata is a MoongatePluginData record constructed as new(id, name, version, author?, description?, dependencies?). id is the value every dependency and error message refers to. It is matched case-insensitively and CODE_CONVENTION.md §10 fixes its shape: reverse-domain, com.github.author.Moongate.plugins.name. The sample uses com.github.moongate-community.moongate.plugins.greeter.

dependencies is a list of MoongatePluginDependencyData(id, minimumVersion?). minimumVersion is inclusive. A list with two entries for the same ID is rejected by the MoongatePluginData constructor itself, before the plugin reaches the registry.

Register declares services and host-managed settings. Plugin code must not start work, open files or sockets, or touch the database; the host starts services later, in priority order. AddConfig<T>() is the explicit host-helper exception: it binds the already-parsed configuration and may append a missing section’s defaults during registration. Use that helper for configuration instead of doing your own file I/O (see What Register may do).

Terminal window
dotnet new classlib -n MyShard.Plugin

Then edit the generated .csproj:

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moongate.Server.Core" Version="0.17.0" ExcludeAssets="runtime" />
<PackageReference Include="Moongate.Scripting" Version="0.17.0" ExcludeAssets="runtime" />
</ItemGroup>
</Project>

Pin the version your host ships; the current one is on nuget.org/packages/Moongate.Server.Core.

ExcludeAssets="runtime" keeps each package’s own .dll out of your build output. The host already loads Moongate.Server.Core.dll, Moongate.Hosting.dll and Moongate.Scripting.dll, so a copy in your bundle would be dead weight. A package the host does not ship must not carry that attribute, so its assembly does end up in the bundle. Plugins that register persisted entities also reference Moongate.Persistence the same way; the host supplies and identity-checks its shared FreeSql and Npgsql contracts.

EnableDynamicLoading makes the SDK copy your private NuGet dependencies next to the build output; without it a dependency the host does not ship is missing from the bundle. Name the project after the bundle you intend to deploy (MyShard.Plugin/ producing plugins/MyShard.Plugin/): the loader expects the entry assembly to carry the bundle directory’s name.

The smallest useful plugin registers one console command:

Create MyShard.Plugin/MyShardPlugin.cs:

using DryIoc;
using Moongate.Server.Core.Data.Commands;
using Moongate.Server.Core.Data.Plugins;
using Moongate.Server.Core.Extensions;
using Moongate.Server.Core.Interfaces.Commands;
using Moongate.Server.Core.Interfaces.Plugins;
using Moongate.Server.Core.Types.Accounts;
using Moongate.Server.Core.Types.Commands;
namespace MyShard.Plugin;
public sealed class MyShardPlugin : IMoongatePlugin
{
public MoongatePluginData Metadata { get; } = new(
"com.github.myshard.moongate.plugins.hello",
"Hello",
new Version(1, 0)
);
public void Register(Container container)
{
container.RegisterCommand<HelloCommand>(
"hello",
"Prints a greeting: hello <name>.",
CommandSourceType.Console,
AccountType.Regular
);
}
}

Create MyShard.Plugin/HelloCommand.cs:

using Moongate.Server.Core.Data.Commands;
using Moongate.Server.Core.Interfaces.Commands;
namespace MyShard.Plugin;
public sealed class HelloCommand : ICommandExecutor
{
public Task ExecuteAsync(CommandContext context)
{
if (context.Arguments.Length != 1)
{
context.PrintError("Usage: hello <name>");
return Task.CompletedTask;
}
context.Print($"Hello, {context.Arguments[0]}!");
return Task.CompletedTask;
}
}

The plugin class needs a public parameterless constructor; the loader instantiates it before any container exists. In a real plugin, put each type in its own file.

Remove the generated Class1.cs; the two files above replace it. Use a .NET 10 SDK and a Unix shell. Set MOONGATE_ROOT below to your initialized server root. Stop an already-running server with shutdown and wait for it to exit before copying the bundle. From the directory containing MyShard.Plugin/, run:

Terminal window
dotnet build MyShard.Plugin/MyShard.Plugin.csproj -c Release
export MOONGATE_ROOT="$HOME/moongate"
mkdir -p "$MOONGATE_ROOT/plugins/MyShard.Plugin"
cp -R MyShard.Plugin/bin/Release/net10.0/. "$MOONGATE_ROOT/plugins/MyShard.Plugin/"

The bundle directory must contain MyShard.Plugin.dll, MyShard.Plugin.deps.json and all private dependencies from the build output. Restart your installed server:

Terminal window
moongate --root-directory "$MOONGATE_ROOT"

For a source checkout, run this from the repository root instead:

Terminal window
dotnet run --project src/Moongate.Server -- --root-directory "$MOONGATE_ROOT"

At the server’s local console, press * to unlock input, then enter:

hello Moongate

The output is Hello, Moongate!. hello with no argument reports Usage: hello <name>. This command is console-only; it needs no player login. If the server refuses the bundle, check Errors that refuse the start. Assembly loading rules and dependency compatibility are in Deployment and loading.

The sample’s Register in SamplePlugin.cs shows the other helpers side by side: AddPersistenceWorld<GreetingNote>() registers a Realm entity and its data facade, RegisterInstance(new GreetingCounter()) shares one instance between a Lua module and a metric provider, AddScriptModule<GreeterModule>() and RegisterScriptEnum<Tone>() publish a Lua table and enum, and AddMetricProvider<GreetingMetricProvider>() adds samples to the diagnostics snapshot. Registration never connects to or changes the database; the host validates every plugin registration as one batch and checks schema readiness before resolving any startup service.

Plugins run in the configured server role. Register Auth entities and account services only in login/standalone, and World entities, Lua world modules and game services only in game/standalone. Persistence registration for an inactive target fails at startup; it does not connect to the other role’s database.

Ship versioned SQL with a persistence plugin

Section titled “Ship versioned SQL with a persistence plugin”

A plugin that registers entities with AddPersistenceAuth<T>() or AddPersistenceWorld<T>() ships its reviewed SQL beside its DLL, under migrations/ with a manifest.json holding a stable component ID, and copies that directory to its output through a <Content Include="migrations/**/*"> item. The migration runner discovers the SQL without loading assemblies; core runs first, then plugin components in ordinal order. See Versioned SQL files for the layout and rules, and Create a persistent entity for a runnable walk-through from entity class to applied migration.

Registration helper What it registers Documented in
AddMoongateService<TService, TImpl>(priority) / AddMoongateService<TService>(instance) A singleton service; if the implementation also implements IMoongateStartupService, it autostarts at the given priority and stops in reverse order. Further overloads accept factories and runtime types this page
RegisterCommand<TExecutor>(name, description, source, minimumAccountType, descriptionMessage) One console/in-game command executor, as a singleton Console commands
RegisterPacketHandler<TPacket, THandler>() One packet handler singleton bound to an incoming packet type this page
RegisterIncomingPacket<TPacket>() An incoming packet type added to the packet registry the host builds at startup, so the server can frame and decode its opcode Packets and handlers
RegisterAsyncPacketHandler<TPacket, THandler>() One async packet handler singleton for I/O; results return to the game loop through PacketContext Packets and handlers
OnEvent<TEvent>(handler) A Func<TEvent, CancellationToken, Task> subscription to one exact IMoongateEvent type, kept for the container’s lifetime this page
AddScriptModule<T>() / RegisterScriptEnum<T>() A [ScriptModule] class as a singleton, published to Lua; or an enum published as a read-only global table Writing a Lua module
AddMetricProvider<T>() An IMetricProvider contribution, singleton, added to the diagnostics collector Registering a metric provider
AddConfig<T>(section) The plugin’s own [section] of config/moongate.toml, bound to T and registered as a singleton Add a config section
AddPersistenceAuth<T>() / AddPersistenceWorld<T>() A typed entity facade for Accounts or Realm, with modules managed internally (needs Moongate.Persistence) PostgreSQL persistence

priority only matters for a service that also implements IMoongateStartupService: the bootstrap starts services in ascending priority and stops them in reverse, so a service takes a lower priority than the services that depend on it. Built-in values:

Priority Service
-1000 RedisConnectionService (every mode)
-900 TimerWheelService
-800 IGameLoopService (GameLoopService)
-10 IUltimaDataService (UltimaDataService)
-5 IDataLoaderService (DataLoaderService; game and standalone)
-4 IMapService (MapService), IMultiService (MultiService); game and standalone, see Client files and world queries
-3 IStartingItemsService
0 (default) ISessionService, IEventBusService, IPluginLoaderService, ICommandSystemService, and any registration that omits priority
10 to 12 The Ultima game services: IItemService, INpcService, IWorldPropsService (10); ILightService, IWeatherService, ISeasonService, IMusicService, IRegionAnnouncer, ItemDecayService (11); regeneration, hunger, crime, murder, combat, guards, jail, bulletin boards, help pages, pet loyalty (12), and others
40 IWorldSaveService (WorldSaveService), IConnectionService
50 IPacketSendService
55 IBookAttachmentService
60 IPacketDispatchService
70 IScriptEngine (LuaScriptEngineService)
75 to 76 The Lua script services that need the engine bound: NpcScriptService, IItemScriptService, IGumpScriptService, IEventScriptService, ISkillScriptService (75); IScheduleService, ISeasonalEventService, IItemTimerService (76)
100 IGameServerService
105 RedisRealmRegistrationService
110 IAdminApiService (AdminGrpcHostService, from the admin plugin; listener disabled by default)
900 IDiagnosticService (DiagnosticService)
1000 IConsoleInputService (ConsoleInputService)

A plugin registering its own startup service picks a priority relative to this table: after IGameLoopService (-800) if it needs to post work to the loop, after IScriptEngine (70) if it needs the engine already bound, and so on. Plugins load before the persistence checks, and persistence schema preparation completes before this startup-service list is resolved, regardless of a plugin service’s priority.

RegisterPacketHandler<TPacket, THandler>() binds one IPacketHandler<TPacket> singleton to one incoming packet type, called synchronously on the game loop thread; a second handler for the same type throws before startup. A packet the plugin defines also needs RegisterIncomingPacket<TPacket>(), because handler registration alone does not add its opcode to the wire registry; see Host integration.

OnEvent<TEvent>(handler) subscribes to the container-owned event bus from Register; the handler is awaited for every published TEvent as long as the container lives.

A plugin keeps its settings in its own table of config/moongate.toml. Write a class with the defaults, implement IConfigSection when some values are not allowed, and add it at the start of Register:

using Moongate.Server.Core.Extensions;
using Moongate.Server.Core.Interfaces.Config;
public sealed class GreeterConfig : IConfigSection
{
public string Greeting { get; set; } = "Welcome";
public GreeterLimits Limits { get; set; } = new(); // [greeter.limits]
public void Validate()
{
if (Limits.MaxPerMinute < 1)
{
throw new InvalidOperationException("greeter.limits.max_per_minute must be at least 1.");
}
}
}
public void Register(Container container)
{
var config = container.AddConfig<GreeterConfig>("greeter");
// Services can now take GreeterConfig in their constructor.
}
[greeter]
greeting = "Welcome"
[greeter.limits]
max_per_minute = 10
  • Property names map to snake_case keys; a nested class is a sub-table.
  • When the file has no [greeter] table, the plugin gets the defaults and the server appends them to the end of the file, so the operator sees every setting. The rest of the file is not rewritten: comments and order stay as they are. If the file cannot be written, a warning is logged and the defaults are used.
  • Validate() runs after reading; an exception stops the start.
  • A section name belongs to one owner: the server’s own sections (network, redis, persistence, …) and a name another plugin already added stop the start with The configuration section [name] is already owned by the server or by another plugin.
  • A key with the section’s name that is not a table (greeter = 5, [[greeter]]) stops the start: appending [greeter] next to it would define the key twice.
  • Settings are read once, at startup; there is no reload.

How it works. At startup the server reads config/moongate.toml once, takes its own sections, and registers the parsed file as a ServerConfigDocument before any plugin’s Register runs. AddConfig claims the name in that document, so no two owners share it, then reads the table or writes the defaults. This host-managed configuration write is allowed in Register; it does not authorize plugin-owned file I/O, background work, sockets or database calls. Everything happens during registration, before any service starts: a bad value never reaches a running server.

Use it in a service. The section is an ordinary singleton, so a service asks for it in its constructor:

public sealed class GreeterService
{
private readonly GreeterConfig _config;
public GreeterService(GreeterConfig config)
{
_config = config;
}
}

A sub-table the plugin wants to hand out on its own is registered with container.RegisterInstance(config.Limits); the Ultima plugin does this so its services receive WorldConfig or CharactersConfig rather than the whole UltimaConfig.

Test it. A test that calls the plugin’s Register directly registers a ServerConfigDocument first, as the server does:

var path = Path.Combine(directory, "moongate.toml");
File.WriteAllText(path, "[greeter]\ngreeting = \"Hi\"\n");
container.RegisterInstance(new ServerConfigDocument(path, TomlSerializer.Deserialize<TomlTable>(File.ReadAllText(path))!, []));
new GreeterPlugin().Register(container);
Assert.Equal("Hi", container.Resolve<GreeterConfig>().Greeting);

The Ultima plugin owns [ultima] and the Admin plugin owns [admin_api] this way.

Subscribe during Register to events from Moongate.Server.Core.Data.Events:

container.OnEvent<PersistenceReadyEvent>(async (_, cancellationToken) =>
{
var items = await container.Resolve<IDataAccess<Item>>()
.GetAllAsync(cancellationToken);
// Load plugin state before startup services begin.
});
container.OnEvent<PersistenceStoppedEvent>((_, _) =>
{
// Persistence is disposed. Release plugin bookkeeping; do not query or save.
return Task.CompletedTask;
});

Item stands for your registered entity; IDataAccess<T> comes from Moongate.Persistence.Interfaces and OnEvent<TEvent> from Moongate.Server.Core.Extensions.

Event Timing and guarantees
PersistenceReadyEvent Plugin registration and persistence initialization have completed successfully, including connection, migration, and schema checks. Persistence is usable; startup services and MoongateStartedEvent follow.
PersistenceStoppedEvent An initialized persistence owner has been disposed successfully, after service shutdown and MoongateStoppedEvent, but before container disposal. It uses a non-cancelable token so shutdown observers can finish.

Both are payload-free, awaited, and emitted at most once per bootstrap lifecycle. Nothing is published when the host has no persistence registration or initialization fails; a later startup failure still publishes PersistenceStoppedEvent, which is a closure signal, not confirmation of a final world save. Handlers run as part of the lifecycle operation, not on the game loop; never await the bootstrap’s StartAsync or StopAsync from one. The event bus logs and isolates observer exceptions, so critical startup validation belongs in a startup service.

For the built-in commands (echo, help, script, console, uptime, version, sql_backup and account, plus the Ultima plugin’s), see Server commands. A command registered with CommandSourceType.InGame can also be typed in game with a leading dot, as .shutdown: the Ultima plugin registers, among others, shutdown, save, broadcast, spawn, add, set, remove, hide, unhide, resurrect, weather, season, time, go, gmtools, jail, pages and moongate.

A command is a class implementing ICommandExecutor, registered with RegisterCommand<T>(name, description, source, minimumAccountType, descriptionMessage) as in the plugin class above. descriptionMessage is optional: the id of a message in data/messages that help shows in the server language instead of description; a plugin can take ids above those Moongate uses. A command’s own texts can go through ILocalizationService too: take it as an optional constructor parameter and use localization.Text(id, english, values), which falls back to the English text when a message is missing. CommandContext gives it Arguments (the tokens after the command name), Print and PrintError (one output line each) and the CancellationToken of the invocation.

A command can also implement ICommandArgumentCompleter to have its arguments completed by TAB on the console: GetArgumentCompletions(previousArguments) gives the values the argument being typed can take, after the arguments already typed ([] for the first one), or none. It runs on the console’s thread, so it returns fixed lists or names read from disk, never game state; an exception is logged and completes nothing, and a value that is empty or holds a space is dropped, since the command parser would split it.

public IReadOnlyList<string> GetArgumentCompletions(IReadOnlyList<string> previousArguments)
{
return previousArguments.Count == 0 ? ["on", "off"] : [];
}

CommandSourceType is a [Flags] enum (InGame, Console), so one command can serve both sources by ORing them, as the built-in echo does. AccountType is Regular, GameMaster, Administrator in ascending order; a console invocation is always treated as Administrator, so the minimum only matters for the same command reached from InGame, where the invoking session’s account type is checked.

Commands run on the thread that called the command system, never on the game loop thread. A command that reads or mutates game state must post its own work item to IGameLoopService and await its outcome before writing the result through CommandContext. The built-in script reload command (src/Moongate.Server/Commands/ScriptCommand.cs) is the reference pattern. The sample’s GreetCommand shows argument validation, including the Enum.IsDefined check that stops a numeric string like "7" from parsing into an undefined enum member.

The built-in Ultima plugin registers the account command: account create <username> <password> [Regular|GameMaster|Administrator]. The level defaults to Regular; the interactive prompt masks the password token and the command awaits IAccountService.CreateAccountAsync before reporting an outcome. The registration also permits in-game administrators, who type it with a leading dot; the api-access subcommand refuses in-game use.

A plugin published as a .nupkg package is installed with mgctl plugin install, which checks its version, unpacks its bundle into <root>/plugins/<id>/ and replaces an older one; see Install and manage plugins. The rest of this section is what a bundle copied by hand must hold.

Build the plugin project in Release and copy its output, everything under bin/Release/net10.0/ and not just the entry DLL, into <root>/plugins/<BundleName>/ on the target server. <root> is resolved at startup from --root-directory, then MOONGATE_ROOT; the server refuses to start with neither.

A bundle is a directory under <root>/plugins/; its name is also the name the loader expects for its entry assembly. A bundle at plugins/mymod/ must contain mymod.dll, its mymod.deps.json, and any private dependency the host does not already ship. A folder whose name starts with a dot is not a bundle and is skipped (mgctl plugin install works in such folders). Bundles load in ordinal directory-name order; the order plugins are registered is decided by the dependency sort, not by load order.

Each bundle gets one collectible AssemblyLoadContext. Its rule for every assembly the bundle references:

  1. Moongate.Core, Moongate.Hosting, Moongate.Server.Core, Moongate.Persistence, Moongate.Persistence.Migrations, FreeSql, FreeSql.Provider.PostgreSQL and Npgsql always resolve from the host, and the version, culture and public key token the plugin referenced must match the host’s copy exactly, older or newer. Build against the exact package version the target host ships.
  2. Every other assembly first tries the host’s own load context. Everything the host ships (other Moongate.* assemblies, Serilog, DryIoc, LuaCSharp, and so on) resolves there as long as the version the plugin references is no newer than the host’s. A bundled copy of such an assembly is dead weight, never an override; a newer reference is treated as not found and, with ExcludeAssets="runtime", the bundle fails to load.
  3. The entry assembly of another bundle (plugins/X/X.dll), found by its assembly name, resolves to the copy that bundle loaded, so the two bundles share its types. A bundle never resolves to itself, and an assembly name carried by two bundles is not shared. See Depending on another plugin.
  4. Only an assembly neither the host nor another bundle has falls through to the bundle: its .deps.json resolver first, then a same-named .dll next to the entry assembly. Two bundles carrying their own copies of one library get two separate instances of its static state.

A plugin that uses the types of another plugin (an interface it registers in the container, a record it publishes) shares them through that plugin’s entry assembly:

  1. Reference the other plugin’s project or package in the .csproj and keep it out of your own bundle: Private="false" on a project reference, ExcludeAssets="runtime" on a package. A copy left in the bundle is ignored, never an override.
  2. Add new MoongatePluginDependencyData("<its id>", minimumVersion) to dependencies: the dependency registers first and a missing or older one refuses the start.
  3. Install both bundles under plugins/.

Only the entry assembly is shared, so keep the types you hand to other plugins to the host’s types and the ones of that assembly: a private library the dependency bundles stays private, and a type from it is two different types on the two sides. A bundle built against a newer version of the shared assembly than the one loaded is refused at start (see below). minimumVersion is compared with the Version of the plugin’s Metadata, not with the assembly version: keep the two moving together, as the Metadata.Version of a plugin read from its own assembly does.

Every failure refuses the start with a named InvalidOperationException. Loading a bundle wraps any failure as Failed to load plugin bundle '{path}'. with the original problem as InnerException:

Message Cause
The plugin entry assembly is missing. The bundle directory holds no DLL named after itself
The assembly contains no public concrete IMoongatePlugin types. No plugin type in the entry assembly
Plugin '{type}' needs a public parameterless constructor. The plugin class cannot be instantiated
Required host persistence contract '{assembly}' is unavailable; private copies are not supported. An identity-checked assembly is missing from the host
Incompatible host persistence contract '{assembly}'; host provides '{identity}'. Private copies are not supported. The plugin was built against a different version of an identity-checked assembly
FileNotFoundException inner exception A dependency newer than the host’s copy, with no private copy in the bundle
Plugin bundle '{bundle}' needs {assembly} {version}, but the bundle of that name provides {version}. A bundle was built against a newer version of another bundle’s entry assembly than the one installed

The registry validates the whole batch before any Register runs; a rejected batch leaves previously registered plugins untouched:

Message Cause
Plugin ID '{id}' is already registered or duplicated. Two plugins share one ID, including a disk plugin colliding with a registered one
Plugin '{id}' requires missing plugin '{dependency}'. A dependency names an ID nothing supplies
Plugin '{id}' requires '{dependency}' >= {minimum}; found {version}. The available plugin is older than minimumVersion
Plugin dependency cycle: first -> second -> first. A cycle in the dependency graph, printed as the path that closed it
Plugin '{id}' failed during registration. The plugin’s own Register threw, including an invalid or already-owned config section; the exception is the InnerException

Test through the real loader: deploy the built bundle under a temporary plugins/<name>/ directory, register the host services your plugin needs in a Container, add PluginLoaderService, and start a MoongateServerBootstrap. Then resolve what your Register added and exercise it: call Lua through the game loop, run commands through CommandSystemService, collect a diagnostics snapshot. tests/Moongate.Tests/Integration/Plugins/SamplePluginTests.cs does exactly this for the sample and is the template to copy. For unit tests that skip the disk, construct new MoongatePluginRegistry(container), call Register(plugin) with an in-memory IMoongatePlugin, and resolve what it added from the same container.

  • Missing EnableDynamicLoading. A dependency the host does not ship never reaches the bundle; see Creating the project.
  • Shipping host assemblies in the bundle. The host’s own copy always wins, so a bundled Moongate.*.dll is dead weight; see Deployment and loading.
  • Doing plugin-owned work, I/O or starting threads in Register. Only host-managed registration helpers (including AddConfig) belong here; nothing may start yet; see The contract.
  • Registering a loop-affine object and touching it from a command without posting. Commands run on the caller’s thread, never the game loop; see Console commands.
  • A [ScriptModule] class with a constructor dependency that is not registered before startup. The engine resolves the instance only when it starts; see Writing a Lua module.

MoongateAdminPlugin is registered in Program.cs after its required Ultima plugin. It ships with the server and is not loaded from plugins/. Its optional HTTP/2 listener uses [admin_api] configuration and existing host-owned services. See Administration API.

Made with ❤️ by Tom

Buy me a coffee