Skip to content

Extending the Framework

For third parties: how to change behaviour without editing the module that owns it. This guide is the concrete walkthrough of the four extension points named in Architecture/02_MODULE_MODEL.md §6.

What you'll have at the end: a working example of each extension point, and a clear sense of which one fits a given change.


0. The honest limitation, first

As of ADR-0010, there is a runtime plugin loader — this supersedes ADR-0002's original "no loader" decision now that its own named revisit trigger has been met. You do not need to fork this repository to write a plugin: drop a Plugin subclass and its manifest under Code/Plugins/, and PluginDiscovery finds and boots it, no editing of any framework module required.

What's still honest about this limitation: this is Stage 1 (in-repo) of a two-stage rollout, per ADR-0010. A plugin's source still has to be compiled into this project once — the "no recompile at all" end state (a compiled Library assembly, no source change) is Stage 2, gated on an editor spike this project hasn't had a chance to run yet. If you need a plugin distributed as a separate, uncompiled artifact today, forking is still the fallback, exactly as before — this project still exists to be forked (01_VISION.md §5). But for the common case — you have this repository, you're adding behaviour without wanting to touch a framework module's own files — write a plugin (§8 below), not a fork.

Two of the four extension points below need no plugin, fork, or recompile at all.

1. Add content — no recompile needed

Items, jobs, and recipes are GameResource assets. Add or edit them in your own Assets/ folder and they load like any other — see 02_ADDING_AN_ITEM.md for the full walkthrough. This is the extension point that needs no code at all, by design (ADR-0004).

2. Change configuration — no recompile needed

Every tuning value that a designer might reasonably want to change is a GameResource property, not a constant — see 00_GETTING_STARTED.md §6. Placing your own configuration asset in Assets/ overrides the shipped default with no code change.

3. Observe or veto behaviour — subscribe to an event

For "when X happens, also do Y" or "prevent X under some condition", subscribe to the relevant event from your plugin's Ready (see §8 for the full plugin shape). This touches nothing in the module you're extending, and — unlike resolving one of its services — needs no DependsOn declaration at all, per 02_MODULE_MODEL.md §5:

public sealed class DoorPurchaseVettingPlugin : Plugin
{
    public override PluginManifest Manifest { get; } = new()
    {
        Id = "door-purchase-vetting",
        DisplayName = "Door Purchase Vetting",
        Version = new Version(1, 0, 0),
        Author = "you",
        RequiresFrameworkVersion = new Version(1, 0, 0),
    };

    public override void Ready(IServiceResolver services)
    {
        var events = services.GetRequiredService<IEventBus>();

        // Cancellable -- see Architecture/04_EVENT_BUS.md §1. Vetoing here never touches
        // World's source; DoorPurchasing is published by PropertyStore.PurchaseAsync before
        // ownership is assigned or money moves -- see Code/World/WorldEvents.cs. No DependsOn
        // on WorldModule needed to subscribe to its event.
        events.Subscribe<DoorPurchasing>(e =>
        {
            if (e.Buyer.HasPermission("banned-from-property"))
                e.Cancel("banned from owning property");
        });
    }
}

Drop this in Code/Plugins/DoorPurchaseVetting/ (§8) — nothing about it requires editing EconomyModule or Ownership, and nothing needs registering in ApplejackBootstrap.Modules.

4. Replace a default implementation — Replace a registered service

For "the framework's default behaviour is wrong for my server, replace it entirely", declare the owning module in your plugin's DependsOn and call IServiceRegistry.Replace<TService> in your own RegisterServices. Because RegisterServices runs in topological order, the owning module's RegisterServices — which registered the default with AddSingleton — is guaranteed to have already run by the time yours does. Code/Plugins/RicherEconomy/ (§8) is a complete, real, working example of exactly this; the shape in miniature:

public sealed class HouseRulesPlugin : Plugin
{
    public override PluginManifest Manifest { get; } = new()
    {
        Id = "house-rules",
        DisplayName = "House Rules",
        Version = new Version(1, 0, 0),
        Author = "you",
        RequiresFrameworkVersion = new Version(1, 0, 0),
    };

    public override IReadOnlyList<Type> DependsOn { get; } = [typeof(InventoryModule)];

    public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
    {
        // InventoryModule is a declared dependency, so its default IInventoryService is
        // already registered -- Replace swaps it for this server's own rules.
        registry.Replace<IInventoryService>(new HouseInventoryService(resolver.GetRequiredService<IItemRegistry>()));
    }
}

Replace throws if nothing was registered yet (nothing to replace) or if it's called a second time for the same service — exactly one replacement, same "no last-one-wins" guarantee AddSingleton gives first-party registration (Architecture/03_SERVICE_REGISTRY.md §2). Two plugins that both try to replace the same service is a real, expected collision with N plugins installed — the exception names both claimants, per that same section.

5. Add UI

Register a HUD element from your plugin's Ready, via IHudRegistry (declare UiModule in DependsOn to resolve it) — see Architecture/11_UI_ARCHITECTURE.md §3. A UI extension never needs access to a module's internals, because UI is held to that boundary just as strictly as gameplay code is (Architecture/11_UI_ARCHITECTURE.md §Ownership). Your panel must be a plain Panel (RegisterHudElement<TPanel> requires TPanel : Panel, new()), not a PanelComponent — see Code/Plugins/RicherEconomy/RicherEconomyHudPanel.razor for a real, correctly-shaped example, and that same file's header comment for a separate, pre-existing gap it surfaced in Code/Ui/HudRoot.razor that currently keeps any registered panel — plugin or first-party — from actually rendering. Your RegisterHudElement call is correct either way; that gap is tracked separately and is not something your plugin needs to work around.

6. Choosing the right extension point

You want to... Use
Add a sandwich, a job, a recipe An asset (02_ADDING_AN_ITEM.md)
Change a price, cooldown, or capacity A configuration override
React to something happening, without changing the rule Subscribe to an event
Veto or modify an outcome Subscribe to a CancellableEvent
Replace how a whole rule works Register a different service implementation
Add a new kind of gameplay entirely A plugin (§8) — or, if it's first-party-scoped
behaviour with no reason to isolate its failures, a module in ApplejackBootstrap.Modules

7. Verify it worked

Whichever extension point you used: the framework's own unit tests still pass unmodified (you should never need to edit a framework test to make your extension work — if you do, you've reached into an implementation detail, not a real extension point), and your addition has its own test or manual verification following the same standards as the rest of this project — see Standards/03_TESTING_STANDARDS.md. For a plugin specifically: once loaded, it shows up in the dev menu's Plugins section (IPluginCatalog, Architecture/23_PLUGIN_SYSTEM.md §4) as Loaded — if it's Disabled or Failed instead, the listed reason names exactly what went wrong, and the same is logged at server start.

8. Writing a plugin

A plugin is one class deriving from Plugin (Applejack.Core.Plugins), plus its manifest, under its own folder in Code/Plugins/<YourPluginName>/ — see Architecture/23_PLUGIN_SYSTEM.md §1 for the full contract and Architecture/01_PROJECT_LAYOUT.md §2 for where it sits relative to Code/Core/Plugins/ (the loader itself — not where your plugin goes). Code/Plugins/RicherEconomy/ is a complete, real, working reference: read its README.md first, then its four files in the order that README names.

The shape, minimally:

public sealed class MyPlugin : Plugin
{
    public override PluginManifest Manifest { get; } = new()
    {
        Id = "my-plugin",                          // unique across your server's plugins
        DisplayName = "My Plugin",
        Version = new Version(1, 0, 0),
        Author = "you",
        RequiresFrameworkVersion = new Version(1, 0, 0),
    };

    // Optional: declare a first-party module to resolve its services (§4), or another
    // plugin's id via Manifest.DependsOn to depend on it. Neither is required for the
    // simplest plugin.
    public override IReadOnlyList<Type> DependsOn { get; } = [];

    // Configure/RegisterServices/Initialize/Ready/Shutdown -- the exact same five-stage
    // lifecycle a first-party module has, per Architecture/02_MODULE_MODEL.md §1-§2. Override
    // only the stages you need; every one has a safe empty default.
}

What you get for free, and what you don't. Your Manifest.Id and Manifest.DisplayName become Id/Name automatically (both are sealed override on Plugin — you set them once, on the manifest, not twice). A malformed manifest, a missing or cyclic plugin dependency, or a lifecycle stage that throws all disable your plugin without stopping the server — see Architecture/23_PLUGIN_SYSTEM.md §4-§5 for exactly what each failure looks like from a server owner's side. What you don't get: a first-party module can never depend on your plugin (the direction only runs one way, Architecture/23_PLUGIN_SYSTEM.md §3), and there is no sandboxing — your plugin runs with full framework access, and a server owner installing it is trusting your code completely (ADR-0010).

No registration step. Unlike a module, you never add your plugin to ApplejackBootstrap.ModulesPluginDiscovery finds every concrete Plugin subclass automatically the moment its source is part of the compiled project (Stage 1; see §0 above for what Stage 2 changes about that last clause).