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 testdiscovers and passesBulletinServiceTests.- Starting a server with two clients, calling
RequestSetBulletinas 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.