Skip to content

Module Model

[KEY] — this document defines the spine every gameplay system attaches to. Read it before writing a module.

The decision to build modules rather than a runtime plugin loader is ADR-0002; this document is that decision made concrete enough to implement against.


1. What a module is

A module is a folder under Code/ (01_PROJECT_LAYOUT.md) containing exactly one type deriving from GameModule, plus everything that module owns.

namespace Applejack.Core.Modules;

/// <summary>
/// A first-party unit of the framework: a declared name, a declared set of dependencies,
/// and a deterministic four-stage boot sequence. See Documentation/Architecture/02_MODULE_MODEL.md.
/// </summary>
public abstract class GameModule
{
    /// <summary>Human-readable name, used in logs and startup-failure messages.</summary>
    public abstract string Name { get; }

    /// <summary>
    /// Modules this one depends on. Resolved topologically at boot; a cycle or a missing
    /// dependency is a hard startup failure naming the modules involved.
    /// </summary>
    public virtual IReadOnlyList<Type> DependsOn { get; } = [];

    /// <summary>
    /// Runs first, before any service is registered. Read configuration, validate
    /// preconditions. Must not touch other modules' services — none are registered yet.
    /// </summary>
    public virtual void Configure(IModuleConfigurationContext context) { }

    /// <summary>
    /// Register this module's services into <paramref name="registry"/>. Runs in dependency
    /// order: every module in <see cref="DependsOn"/> has already completed its own
    /// RegisterServices call by the time this one runs, so resolving one of their
    /// already-registered services from <paramref name="resolver"/> here is safe. Not safe:
    /// resolving from a module not in <see cref="DependsOn"/>, or a service its owner only
    /// registers in <see cref="Initialize"/> rather than here.
    /// </summary>
    public virtual void RegisterServices(IServiceRegistry registry, IServiceResolver resolver) { }

    /// <summary>
    /// All modules have registered services; resolve collaborators and wire internal state.
    /// Do not publish events yet — not every module has reached <see cref="Initialize"/>.
    /// </summary>
    public virtual void Initialize(IServiceResolver services) { }

    /// <summary>
    /// Every module has initialized. Safe to publish events, start timers, and begin
    /// normal operation.
    /// </summary>
    public virtual void Ready(IServiceResolver services) { }

    /// <summary>
    /// Reverse dependency order. Release resources, flush pending writes, unsubscribe.
    /// </summary>
    public virtual void Shutdown() { }
}

Each stage exists because the previous one is unsafe to skip:

Stage May do May not do
Configure Read config, validate Resolve any service
RegisterServices Register this module's services; resolve an already-registered DependsOn service Resolve from an undeclared module, or a service only registered in Initialize
Initialize Resolve services from DependsOn, wire internal collaborators Publish events
Ready Publish events, start timers
Shutdown Release, flush, unsubscribe Assume any other module is still alive

2. Boot sequence

sequenceDiagram
    participant B as Bootstrap
    participant R as Dependency resolver
    participant M1 as Module A
    participant M2 as Module B (depends on A)

    B->>R: topologically sort [A, B, ...]
    R-->>B: ordered list, or a named cycle/missing-dependency failure

    loop Configure, in order
        B->>M1: Configure(context)
        B->>M2: Configure(context)
    end
    loop RegisterServices, in order
        B->>M1: RegisterServices(registry)
        B->>M2: RegisterServices(registry)
    end
    Note over B: All services now resolvable
    loop Initialize, in order
        B->>M1: Initialize(provider)
        B->>M2: Initialize(provider)
    end
    loop Ready, in order
        B->>M1: Ready(provider)
        B->>M2: Ready(provider)
    end

Each stage completes for every module before the next stage begins for any module. A module in Initialize may safely resolve a service registered by a dependency, because every module already finished RegisterServices. This is the entire reason the stages are separate rather than one Start() method: initialization order and registration order are different problems, and conflating them is exactly the load-order coupling D-08 describes.

Shutdown runs in reverse dependency order — a module's dependencies are still alive while it shuts down, but nothing depending on it is.

3. Dependency resolution

The resolver keys the graph on identity (GameModule.Id), not Type, then does an ordinary topological sort (Kahn's algorithm) over the resulting node set:

public abstract class GameModule
{
    // Defaults to GetType().FullName -- exactly what the resolver keyed on before this
    // property existed, so no first-party module needs to change.
    public virtual string Id => GetType().FullName ?? GetType().Name;

    // Type-based, resolved by looking up each Type's owning module's Id at graph-build time.
    public virtual IReadOnlyList<Type> DependsOn { get; } = [];

    // Identity-based, for a dependency that can't be named via typeof(...) -- chiefly a
    // plugin in a separate assembly depending on another plugin.
    public virtual IReadOnlyList<string> DependsOnIds { get; } = [];

    // True only for third-party Plugin instances (Applejack.Core.Plugins §Plugins).
    public virtual bool IsPlugin => false;
}

Id exists because Type does not survive an assembly boundary: a plugin shipped as a separate S&box Library cannot express "I depend on plugin applejack.economy-extras" with typeof(...), only with a string. First-party modules never need to touch Id or DependsOnIds — the default keeps every existing DependsOn => [typeof(...)] declaration working unchanged, projected onto identity by the resolver itself.

Four failure shapes, all hard startup failures that name the modules involved — never a silent fallback ordering:

// Thrown by the resolver. For a first-party module, never caught anywhere in gameplay code
// -- a misconfigured dependency graph is a build-time problem, and the loudest possible
// failure is correct. For a plugin, caught by the plugin boot runner, per
// 23_PLUGIN_SYSTEM.md §6.1 -- Kind/InvolvedIds exist for exactly that caller, so it can
// identify which node(s) to quarantine without parsing Message.
public sealed class ModuleDependencyException : Exception
{
    public ModuleDependencyFailureKind Kind { get; }
    public IReadOnlyList<string> InvolvedIds { get; }

    public static ModuleDependencyException MissingDependency(GameModule declaringModule, Type missing) => new(...);
    public static ModuleDependencyException MissingDependency(GameModule declaringModule, string missingId) => new(...);
    public static ModuleDependencyException DuplicateId(string id, GameModule first, GameModule second) => new(...);
    public static ModuleDependencyException ModuleDependsOnPlugin(GameModule module, GameModule plugin) => new(...);
    public static ModuleDependencyException Cycle(IReadOnlyList<GameModule> cycle) => new(...);
}

DuplicateId and ModuleDependsOnPlugin exist because of Id/IsPlugin, not because existing behaviour needed them: two first-party modules already couldn't collide (Type was always unique), and a module could not previously depend on something that didn't exist yet. Both become real, reachable failure modes once plugins can register arbitrary ids and depend on modules — see 23_PLUGIN_SYSTEM.md. The direction rule is enforced only one way: a first-party module depending on a plugin is a hard boot failure; a plugin depending on a first-party module is exactly the normal, expected case.

This is still always a hard, thrown failure at the resolver level, for every caller. What differs is only what happens after the throw: a first-party boot lets it propagate; the plugin boot runner catches it and reacts using Kind/InvolvedIds instead of crashing. The resolver itself has no concept of "plugin failure isolation" — that policy lives entirely in the caller, per 23_PLUGIN_SYSTEM.md §6.1.

This is unit-tested directly, per the Definition of Done: every failure shape above has a regression test asserting the specific exception and the names it carries (UnitTests/Core/Modules/ModuleDependencyResolverTests.cs).

4. The module list

The set of modules is fixed at compile time and lives in one bootstrap file, Code/Core/Modules/ApplejackBootstrap.cs:

internal static class ApplejackBootstrap
{
    // Declared once. Order here is irrelevant -- the resolver sorts by DependsOn.
    private static readonly GameModule[] Modules =
    [
        new CoreModule(),
        new CharacterModule(),
        new ItemsModule(),
        new InventoryModule(),
        // ...
    ];
}

There is deliberately no discovery mechanism scanning assemblies for GameModule subclasses. Reflection over gameplay types at runtime is forbidden; an explicit list is one line to add to and impossible to load out of the order you did not intend.

5. Communication rules

A module may depend on, and only on:

  1. Modules it declares in DependsOn, and then only through their registered service interfaces (never their concrete types — see 03_SERVICE_REGISTRY.md).
  2. Events on the event bus — including events from modules it has not declared a dependency on, since observing an event creates no coupling to the publisher's internals (see 04_EVENT_BUS.md).

Concrete types are internal by default. Making a type public without a service interface around it is a review rejection — it is the module boundary equivalent of a public field.

6. Extension points

The four ways a third party (or a first-party module playing by the same rules — 00_CONSTITUTION.md's "dogfooding") extends the framework without a runtime plugin loader:

Extension point Mechanism Example
Replace a default implementation Register a different implementation against the same service interface A custom ITransferPolicy enforcing a house rule
Observe or modify behaviour Subscribe to a typed event; cancel explicitly if cancellable Veto a door purchase for a custom reason
Add content GameResource assets A new item, job, or recipe — no code
Add UI Register a panel A custom HUD element

None of these require recompiling other modules, but all of them currently require recompiling this project, since there is no runtime assembly loading — the accepted cost of ADR-0002, revisited if third-party demand for uncompiled distribution appears.

See 21_EXTENSION_SURFACE.md for the full, named list of what these four points actually cover today, and the convention that keeps ADR-0002's other revisit trigger — extension interfaces stable across two milestones — checkable.

7. Failure isolation

An exception thrown by an event handler is caught at the publish site, logged with the handler and the module that registered it named, and does not abort the event for other subscribers or crash the server. See 12_LOGGING_AND_DIAGNOSTICS.md and 13_ERROR_HANDLING.md for the exact mechanism — this is the one genuinely good property of the legacy hook dispatcher (D-07), kept deliberately, without the monkey-patch that came with it.

An exception thrown during boot (Configure through Ready) is not isolated — it is a hard startup failure. A module that cannot initialize correctly should not run partially.

8. Worked example

A minimal but complete module, showing every stage doing real work:

// -----------------------------------------------------------------------------
// NeedsModule.cs
//
// Owns the generic ambient-need framework (hunger, stamina, thirst, ...). Needs
// themselves are configuration, not code -- see 05_CONFIGURATION.md.
//
// Owned by:    Applejack.Needs
// Depends on:  Applejack.Core (events, config, persistence), Applejack.Characters
// Legacy ref:  plugins/hunger, plugins/stamina
// -----------------------------------------------------------------------------
namespace Applejack.Needs;

/// <summary>
/// Owns need definitions and each character's need state. See
/// Documentation/Architecture/02_MODULE_MODEL.md for the module contract this implements.
/// </summary>
public sealed class NeedsModule : GameModule
{
    public override string Name => "Needs";
    public override IReadOnlyList<Type> DependsOn => [typeof(CoreModule), typeof(CharacterModule)];

    private NeedsConfiguration _config = null!;

    public override void Configure(IModuleConfigurationContext context)
    {
        // Legacy: sh_config.lua "Hunger Fill Time" / "Hunger Damage". See
        // Documentation/Legacy/08_NEEDS_AND_STATE.md §2.
        _config = context.Load<NeedsConfiguration>();
    }

    public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
    {
        registry.AddSingleton<INeedRegistry>(new NeedRegistry(_config.Definitions));
        registry.AddSingleton<INeedService>(new NeedService());
    }

    public override void Initialize(IServiceResolver services)
    {
        // Character is a dependency, so its service is guaranteed registered.
        var characters = services.GetRequiredService<ICharacterLifecycle>();
        var needs = services.GetRequiredService<INeedService>();
        characters.CharacterSpawned += needs.OnCharacterSpawned;
    }

    public override void Ready(IServiceResolver services)
    {
        services.GetRequiredService<INeedService>().StartTicking();
    }

    public override void Shutdown()
    {
        // NeedService owns its own timer disposal; nothing else to release here.
    }
}

9. Testing a module

Per Standards/03_TESTING_STANDARDS.md §1, the module type itself — GameModule, IServiceRegistry, IServiceResolver — is engine-agnostic and unit-testable directly:

[TestMethod]
public void NeedsModuleInitializeSubscribesToCharacterSpawned()
{
    var services = new FakeServiceRegistry();
    var characters = new FakeCharacterLifecycle();
    services.AddSingleton<ICharacterLifecycle>(characters);

    var module = new NeedsModule();
    module.RegisterServices(services, services.BuildProvider());
    module.Initialize(services.BuildProvider());

    characters.RaiseCharacterSpawned(new FakeCharacter());

    Assert.IsTrue(services.Resolve<INeedService>().HasStateFor(characters.Last));
}

No Scene, no GameObject, no host — exactly the constraint Standards/03_TESTING_STANDARDS.md requires.