ADR-0002 — Module architecture¶
Status: 🚫 Superseded by ADR-0010 — Runtime plugin loader Date: 2026-07-27 Supersedes: ADR-0001 — Plugin-first architecture Superseded by: ADR-0010 — Runtime plugin loader
2026-07-31 update: superseded, not wrong. Everything below was correct at the time — the loader's dependencies (event bus subscribers,
Replace,Shutdown) were unexercised, and adding a loader on top of them would have shipped untested infrastructure to plugin authors. Phase 1 of the Plugin System work closed that gap; ADR-0010 records the actual "add the loader later" decision this ADR's own Notes section anticipated. First-party modules, the five-stage lifecycle, the service registry, and the event bus described here are all unchanged — only the "no runtime loader" half of the decision is superseded.
Context¶
The project's founding documentation committed to a strict plugin-first architecture: core hosts plugins only, all gameplay lives in plugins, with a full discovery / manifest / validation / dependency-resolution lifecycle (ADR-0001).
Three facts bear on whether to build that.
The legacy evidence. Applejack was genuinely modular — 16 plugins covering policy
(officials), needs (hunger, stamina), map data (doors, spawnpoints,
prisonpoints) and cosmetics. Removing officials leaves arrest mechanisms intact with
nobody authorised to use them; removing hunger removes the Chef job. That is good design.
But its plugin loader is 98 lines and the direct cause of two of the worst defects in the
codebase: it monkey-patches the global hook.Call so that any plugin returning a non-nil
value silently suppresses core behaviour, in undefined order
(D-07); and because plugins are a separate lifecycle,
item and job definitions must interrogate it at file scope
(if GM:GetPlugin("hunger") then …), making load order load-bearing and silently breakable
(D-08).
The modularity came from the boundaries. The loader was a liability.
The engine. S&box hot-reloads code as a first-class feature, and the Libraries system is the supported mechanism for self-contained reusable code and assets shared with an organisation or the community. A bespoke loader would reimplement both, worse.
The cost. A correct runtime plugin system needs discovery, manifest schema and parsing, validation, semantic version resolution, dependency ordering with cycle detection, service registration, isolation, error containment and hot-reload — before a single item exists. On a project whose stated philosophy is small → test → review → expand, this is the largest possible first bite, and everything else waits on it.
Doing nothing is not an option: without a decision, the roadmap's next milestone is "build the plugin SDK".
Decision¶
We will build Applejack as a set of modules — ordinary assemblies under
Code/with hard boundaries, declared dependencies and a deterministic lifecycle — and serve third-party extension through stable interfaces, typed events and documented extension points rather than a bespoke runtime loader.
Concretely:
A module is a folder under Code/ with a README.md, a namespace, and one type deriving
from GameModule that declares its dependencies:
/// <summary>
/// Owns character inventories, world containers, and all transfers between them.
/// </summary>
public sealed class InventoryModule : GameModule
{
public override string Name => "Inventory";
public override IReadOnlyList<Type> DependsOn => [typeof(CoreModule), typeof(ItemsModule)];
public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
{
registry.AddSingleton<IInventoryService>(new InventoryService(resolver.GetRequiredService<IItemRegistry>()));
registry.AddSingleton<ITransferPolicy>(new DefaultTransferPolicy());
}
}
Lifecycle, deterministic and topologically ordered by DependsOn:
Configure → RegisterServices → Initialize → Ready → Shutdown
A missing dependency or a cycle is a hard startup failure naming the modules involved. Nothing resolves a dependency by calling a global at file scope.
Communication. A module may depend only on:
1. modules it declares in DependsOn, and then only via their public interfaces;
2. events published on the event bus.
Concrete types are internal unless there is a reason. Reaching into another module's
implementation is a review rejection.
Extension points. Third parties extend through: registered service interfaces (replace
the default implementation), typed events (observe, modify, or cancel — explicitly, never by
returning a truthy value), GameResource assets (items, jobs, recipes), and registered UI
panels.
Failure isolation is kept. An exception in an event handler is logged with the offending handler named, and does not abort the event or the server — the one genuinely good property of the legacy dispatcher.
Dogfooding is kept. Character, Inventory, Items and Economy are built against exactly the public surface a third party would use. If a first-party module needs something the extension API cannot express, that is a gap in the extension API, and it gets fixed there.
Alternatives considered¶
Strict plugin-first, as ADR-0001 specified¶
Attractive, and not a bad idea: it forces the extension API to be complete on day one, because there is no other way to write gameplay. It proves the SDK by construction, and it means a third party can genuinely do everything a first party can.
Rejected on cost and sequencing. The loader must be complete and correct before any gameplay exists, which inverts the project's stated build order and delays every testable outcome by months. The dogfooding benefit is obtainable without it, by holding first-party modules to the extension API by review. And the legacy experience is that the loader is where the defects live, while the boundaries are where the value is.
Modules, plus a runtime plugin loader later¶
Build modules now; add discovery and manifests once the interfaces have stabilised.
This is close to what we are doing and remains open — deliberately. Building the loader later is cheaper than building it now, because by then the extension interfaces will have been exercised by six first-party modules and we will know what a manifest actually needs to declare. If third-party demand appears, this is the path, and it will supersede this ADR. We are not choosing against it; we are choosing not to do it first.
A single assembly with namespace conventions¶
Simplest, and fastest to start. Rejected: nothing enforces the boundary, so it erodes. The legacy codebase demonstrates the endpoint — a 965-line player metatable that eleven systems write to directly (Legacy/13).
Full dependency-injection container with lifetimes and scoping¶
Rejected as more machinery than the problem needs. A simple registry with singleton registration and constructor resolution covers every case we have; scoping and transients can be added if a real need appears. See Architecture/03_SERVICE_REGISTRY.md.
Consequences¶
Positive¶
- Gameplay work starts immediately; the first testable milestone is weeks, not months.
- Compile-time enforcement of boundaries — a module cannot accidentally reach into another's internals.
- Deterministic, debuggable startup with loud failures instead of silent misordering.
- No global mutation anywhere; the D-07 class of defect is structurally impossible.
- Hot reload comes free from the engine.
Negative¶
- Third parties cannot ship a module without recompiling the project until and unless a loader is added. This is a real limitation and the main cost of this decision. Mitigation: S&box Libraries cover the distribution case, and asset-driven content (items, jobs, recipes) needs no code at all.
- Boundary discipline is enforced by review and
internal, not by process isolation. A determined contributor can still create coupling. - Adding the loader later means retrofitting manifests onto modules that did not need them.
Neutral¶
- Vocabulary change. "Module" now means a first-party unit of the framework; "plugin"
means a third-party extension.
Documentation/uses these consistently (Standards/01_NAMING.md). - The module list is fixed at compile time and lives in one bootstrap file.
Compliance¶
- Module boundaries: concrete types are
internal; cross-module references are caught by the compiler. - Dependency declaration: startup fails loudly on a missing dependency or a cycle, and there is a unit test asserting both.
- Event handler isolation: unit-tested — a throwing handler must not prevent other handlers from running.
- Dogfooding: reviewed. A first-party module using something not available to an extension is raised as an extension-API gap.
Notes¶
Revisit this when a third party asks to ship code without recompiling, or when the extension interfaces have been stable across two milestones. Either is a good trigger for the "modules plus a loader" alternative above. See Architecture/21_EXTENSION_SURFACE.md for what "extension interfaces" names concretely, and how to check the second trigger without re-reading the whole commit history.
Explicitly not revisited for: "it would be more elegant". The cost is in the machinery, and the machinery does not become cheaper by being wanted.