Skip to content

Writing a Module

A worked, end-to-end example: a Bulletin module. An admin sets a short server-wide announcement; every player sees it in their HUD; it persists across restarts. Small enough to read in one sitting, complete enough to exercise every part of Architecture/02_MODULE_MODEL.md.

What you'll have at the end: a real module — service, GameModule, persistence, a networked property, a HUD panel, and a unit test — registered and running.


1. Create the folder and its README first

Per Standards/02_DOCUMENTATION_STANDARDS.md §2, a module gets its README before its first line of code:

Code/Bulletin/README.md
# Bulletin

**Purpose.** A short, server-wide announcement admins can set, visible to every player.

**Responsibilities.**
- Owns the current bulletin text and who last set it.
- Persists the bulletin across restarts.

**Not responsible for.** Chat messages (owned by Chat), admin permission checks beyond
"is this connection an admin" (owned by Character's permission resolution).

**Dependencies.** Core (events, persistence, logging). Character (permission check for
`RequestSetBulletin`).

**Public API.** `IBulletinService.Current` (read), `IBulletinService.RequestSetBulletin`
(admin-only, via RPC).

**Events published.** `BulletinChanged`.

**Persistence.** `bulletin/server` -- one document, schema version 1.

**Future improvements.** No history/log of past bulletins; add if ever requested.

2. Define the service interface

// -----------------------------------------------------------------------------
// IBulletinService.cs
//
// Owns the current server bulletin. The single source of truth other modules and
// UI read from -- see Documentation/Architecture/03_SERVICE_REGISTRY.md.
//
// Owned by:    Applejack.Bulletin
// Depends on:  Applejack.Core (persistence, events)
// -----------------------------------------------------------------------------
namespace Applejack.Bulletin;

/// <summary>
/// Owns the current server-wide bulletin. See Documentation/Guides/01_WRITING_A_MODULE.md.
/// </summary>
public interface IBulletinService
{
    /// <summary>The current bulletin text. Empty string if none has been set.</summary>
    string Current { get; }

    /// <summary>
    /// Sets the bulletin. Caller must already have been validated as an admin -- this
    /// method does not re-check permission itself; the RPC handler that calls it does,
    /// per Documentation/Architecture/07_NETWORKING.md.
    /// </summary>
    Task SetAsync(string text, Character setBy);
}

3. Implement it

// -----------------------------------------------------------------------------
// BulletinService.cs
// -----------------------------------------------------------------------------
namespace Applejack.Bulletin;

internal sealed class BulletinService : IBulletinService
{
    private readonly IPersistenceBackend _persistence;
    private readonly IEventBus _events;
    private readonly IGameLog _log;

    public string Current { get; private set; } = "";

    public BulletinService(IPersistenceBackend persistence, IEventBus events, IGameLog log)
    {
        _persistence = persistence;
        _events = events;
        _log = log;
    }

    internal async Task LoadAsync()
    {
        var result = await _persistence.LoadAsync<BulletinDocument>(new DocumentKey("bulletin", "server"));
        if (result.Succeeded) Current = result.Value!.Text;
    }

    public async Task SetAsync(string text, Character setBy)
    {
        Current = text;
        _log.Info(LogCategory.Module, "bulletin changed", ("setBy", setBy.Id));

        var saveResult = await _persistence.SaveAsync(
            new DocumentKey("bulletin", "server"), new BulletinDocument { Text = text });
        if (!saveResult.Succeeded)
            _log.Error(LogCategory.Persistence, "failed to save bulletin", ("reason", saveResult.FailureMessage!));

        _events.Publish(new BulletinChanged(text, setBy));
    }
}

/// <summary>Persisted shape. See Documentation/Architecture/06_PERSISTENCE.md §2.</summary>
internal sealed class BulletinDocument
{
    public string Text { get; set; } = "";
}

public readonly record struct BulletinChanged(string Text, Character SetBy) : IEvent;

4. The module

// -----------------------------------------------------------------------------
// BulletinModule.cs
// -----------------------------------------------------------------------------
namespace Applejack.Bulletin;

public sealed class BulletinModule : GameModule
{
    public override string Name => "Bulletin";

    // PersistenceModule is required here, not just CoreModule -- RegisterServices below
    // resolves IPersistenceBackend, which PersistenceModule registers. Omitting it would
    // "work by accident rather than by the graph" (GameModule.RegisterServices's own doc
    // comment) -- a real gap this guide's own first draft had, fixed once the module was
    // actually built (Code/Bulletin/BulletinModule.cs).
    public override IReadOnlyList<Type> DependsOn =>
        [typeof(CoreModule), typeof(PersistenceModule), typeof(CharacterModule)];

    public override void RegisterServices(IServiceRegistry registry, IServiceResolver resolver)
    {
        // Safe to resolve here: IPersistenceBackend/IEventBus/IGameLog are all registered
        // by CoreModule, declared in DependsOn above -- see 02_MODULE_MODEL.md §1 and
        // Architecture/03_SERVICE_REGISTRY.md §2 for why RegisterServices can resolve a
        // DependsOn module's already-registered services.
        registry.AddSingleton<IBulletinService>(new BulletinService(
            resolver.GetRequiredService<IPersistenceBackend>(),
            resolver.GetRequiredService<IEventBus>(),
            resolver.GetRequiredService<IGameLog>()));
    }

    public override void Initialize(IServiceResolver services)
    {
        // Load happens here, not RegisterServices -- persistence is guaranteed
        // registered by CoreModule, which we depend on. See 02_MODULE_MODEL.md §1.
        _ = ((BulletinService)services.GetRequiredService<IBulletinService>()).LoadAsync();
    }
}

Register it in the bootstrap list, per Architecture/02_MODULE_MODEL.md §4:

// Code/Core/Modules/ApplejackBootstrap.cs
new BulletinModule(),

5. Replicate it and expose the admin RPC

// -----------------------------------------------------------------------------
// BulletinNetworkComponent.cs
//
// Legacy ref: none -- new feature.
// -----------------------------------------------------------------------------
namespace Applejack.Bulletin;

public sealed class BulletinNetworkComponent : Component
{
    /// <summary>
    /// Replicated to everyone -- a public announcement, not private state, so plain [Sync]
    /// with FromHost is correct: only the host may set it, but nothing about who receives
    /// it is restricted. See Architecture/07_NETWORKING.md §3.
    /// </summary>
    [Sync(SyncFlags.FromHost)] public string Bulletin { get; private set; } = "";

    [Rpc.Host]
    public void RequestSetBulletin(string text)
    {
        var character = Rpc.Caller.GetCharacter();
        if (character is null || !character.HasPermission("admin.bulletin")) return;
        if (text.Length > 200) return; // bounded, per Standards/00_CODING_STANDARDS.md §3

        _ = _bulletins.SetAsync(text, character).ContinueWith(_ => Bulletin = _bulletins.Current);
    }

    private IBulletinService _bulletins = null!;
    // Scene.GetSystem<T>() only resolves GameObjectSystem<T> registrations -- ApplejackBootstrap
    // is one, IBulletinService isn't. Go through ApplejackBootstrap.Services instead; see
    // Code/Core/Modules/ApplejackBootstrap.cs.
    protected override void OnAwake() =>
        _bulletins = Scene.GetSystem<ApplejackBootstrap>().Services!.GetRequiredService<IBulletinService>();
}

6. A minimal panel

@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent

<root>
    @if (!string.IsNullOrEmpty(Bulletin.Bulletin))
    {
        <div class="bulletin-banner">@Bulletin.Bulletin</div>
    }
</root>

@code {
    [Property] public BulletinNetworkComponent Bulletin { get; set; } = null!;
    protected override int BuildHash() => Bulletin.Bulletin.GetHashCode();
}

7. Test it

[TestClass]
public class BulletinServiceTests
{
    [TestMethod]
    public async Task SetAsyncUpdatesCurrentAndPublishesEvent()
    {
        var persistence = new FakePersistenceBackend();
        var events = new FakeEventBus();
        var service = new BulletinService(persistence, events, new FakeGameLog());

        await service.SetAsync("Server restart at 8pm", FakeCharacter.Admin);

        Assert.AreEqual("Server restart at 8pm", service.Current);
        Assert.IsTrue(events.WasPublished<BulletinChanged>());
    }
}

Only BulletinService is tested here — no Scene, no Component, no host, per Standards/03_TESTING_STANDARDS.md §1. The [Rpc.Host] permission check and the panel are covered by manual multiplayer testing and the exploit checklist before merge.

8. Verify it worked

  • dotnet test discovers and passes BulletinServiceTests.
  • Starting a server with two clients, calling RequestSetBulletin as an admin updates the banner for both clients.
  • Restarting the server preserves the last-set bulletin.
  • A non-admin calling RequestSetBulletin (via the console, bypassing UI) has no effect — this is the exploit-checklist item "call it without the required permission."

That's a complete module: README, service, GameModule, persistence, replication, an RPC re-validated host-side, a panel, and a test. Every other module in this project — Economy, Crime, Jobs — is a larger version of exactly this shape.