Skip to content

Player Menu

The generalized replacement for legacy's tabbed F1 frame (legacy/Applejack/gamemode/derma/cl_menu.lua's cider_Menu). Composes two patterns that already exist rather than inventing a third: IHudRegistry's registration-time decoupling (11_UI_ARCHITECTURE.md §3) and the on-demand-panel toggle AdminToolsRootComponent established (11_UI_ARCHITECTURE.md §7). Visual language is 23_UI_DESIGN_SYSTEM.md — not restated here.


1. IMenuRegistry

Mirrors IHudRegistry exactly — a module contributes a tab without PlayerMenu knowing that module exists:

namespace Applejack.Ui;

public readonly record struct MenuTabRegistration(Type PanelType, string Title, string Icon, int Order);

public interface IMenuRegistry
{
    /// <summary>Registers a tab panel type to be instantiated once the player menu builds.
    /// Called from a gameplay module's own Ready -- the menu never needs to know
    /// <typeparamref name="TPanel"/>'s type exists ahead of time. <paramref name="icon"/> is
    /// one of the names §6 of 23_UI_DESIGN_SYSTEM.md still leaves open.</summary>
    void RegisterMenuTab<TPanel>(string title, string icon, int order) where TPanel : Panel, new();

    /// <summary>Every registration made so far, in ascending <c>Order</c>, read by
    /// PlayerMenu.razor when it builds.</summary>
    IReadOnlyList<MenuTabRegistration> RegisteredTabs { get; }
}

internal sealed class MenuRegistry : IMenuRegistry
{
    private readonly List<MenuTabRegistration> _tabs = [];

    public void RegisterMenuTab<TPanel>(string title, string icon, int order) where TPanel : Panel, new() =>
        _tabs.Add(new MenuTabRegistration(typeof(TPanel), title, icon, order));

    public IReadOnlyList<MenuTabRegistration> RegisteredTabs => [.. _tabs.OrderBy(t => t.Order)];
}

Registered by UiModule, alongside IHudRegistryCode/Ui/ stays the one place both registries live, since both exist for the same reason (§3's "the HUD/menu root stays ignorant of which gameplay modules exist").

2. PlayerMenuRootComponent

Sibling to AdminToolsRootComponent, deliberately simpler — every player may open this menu, so there is no permission gate to check, just a toggle:

namespace Applejack.Ui;

public sealed class PlayerMenuRootComponent : Component
{
    [Property] public GameObject Menu { get; set; } = null!;

    protected override void OnUpdate()
    {
        if (Input.Pressed("player_menu"))
            Menu.Enabled = !Menu.Enabled;
    }
}

One action name (player_menu), rebindable through S&box's own input-binding settings, same as admin_panel/dev_menu. Default key is ` (backquote), set in ProjectSettings/Input.config — F1 was the legacy convention but isn't carried over as the default here.

3. PlayerMenu.razor

Builds a nav rail imperatively from IMenuRegistry.RegisteredTabs, the same TypeLibrary.Create<Panel>(Type) mechanism HudRoot.razor already uses for a type only known at runtime — but unlike HudRoot, only the selected tab's panel exists as a child at a time, swapped on click rather than all being mounted simultaneously (the legacy DPropertySheet kept every sheet alive at once; this codebase has no read yet needing that, and mounting only the visible tab avoids paying every tab's BuildHash/query cost while hidden):

<root>
    <div class="player-menu">
        <div class="player-menu-rail">
            @foreach (var tab in Tabs())
            {
                <div class="player-menu-rail-icon @(tab.Title == _selected ? "active" : "")"
                     onclick="@(() => Select(tab.Title))">
                    <i class="@tab.Icon" />
                </div>
            }
        </div>
        <div class="player-menu-content" @ref="_content" />
    </div>
</root>

@code {
    private string _selected = "";
    private Panel? _content;

    protected override int BuildHash() => _selected.GetHashCode();

    private IReadOnlyList<MenuTabRegistration> Tabs() =>
        Scene.GetSystem<ApplejackBootstrap>().Services!.GetRequiredService<IMenuRegistry>().RegisteredTabs;

    protected override void OnAwake()
    {
        var tabs = Tabs();
        if (tabs.Count > 0) Select(tabs[0].Title);
    }

    private void Select(string title)
    {
        _selected = title;
        _content?.DeleteChildren();
        var tab = Tabs().FirstOrDefault(t => t.Title == title);
        if (tab.PanelType is not null)
            _content?.AddChild(TypeLibrary.Create<Panel>(tab.PanelType));
    }
}

ASSUMED, NOT VERIFIED THIS PASS (same caveat every panel in this codebase carries, 11_UI_ARCHITECTURE.md §7): @ref capturing a child Panel reference the way this sketch assumes; Panel.DeleteChildren() as the real method name for clearing a container before swapping in a new tab's panel. If either assumption is wrong, HudRoot.razor's own "build every slot container in OnAwake, never torn down" approach is the fallback — mount every tab once, toggle visibility instead of swapping children.

4. Tabs — what ships and what doesn't

Tab Panel Backing Gate
Character CharacterTab (new) ICharacterService, IJobService/TeamGroup — both already exist, plus two new Character fields (§5) None (self-service)
Character select (not a tab — shown outside PlayerMenu, see §5.1) CharacterSelectPanel (Milestone 13) ICharacterService.GetSelectableCharactersAsync, CharacterNetworkComponent's new selection/deletion RPCs None (self-service; ownership enforced host-side)
Inventory InventoryPanel (existing, reused as-is) CharacterInventoryComponent None
Laws LawsTab (new) ILawService (already exists — §6) Read: none. Edit: law.edit
Info InfoTab (new) StaticContentConfiguration (§7) None
Help HelpTab (new) ICommandRegistry (already exists — §8) None
Changelog ChangelogTab (new) StaticContentConfiguration (§9) None
Credits CreditsTab (new) StaticContentConfiguration (§10) None

InventoryPanel stays registered as a standing HUD element too (Code/Inventories/InventoryModule.cs:94) — a player checking their bag mid-interaction shouldn't need to open the full menu. The tab and the HUD element are the same Panel type, registered against both registries; no duplication of markup.

Still deliberately not built as part of this menu: Store (legacy read GM.Items's Cost/Batch/Store/Category fields — no purchasable/craftable model exists in Code/Items/Code/Economy today; this is new domain design, not a wiring task, per the prior design discussion) and Donate (implies a payment processor this codebase has no story for at all — out of scope, not deferred scope). Help/Changelog/Credits were dropped in this document's first draft on the same reasoning as Donate — reversed on direct instruction, since unlike Donate all three have a real, cheap backing (§8–§10) once looked at properly. Clan (legacy's per-character clan tag has no modeled equivalent anywhere in Code/Characters — still out of scope until it does).

5. Character tab — confirmed fields, and the two new ones needed

Code/Characters/Character.cs reviewed directly (resolving this document's earlier "not confirmed" caveat). Today Character holds exactly: Id (CharacterId), OwnerAccountId (SteamId), Name, IsInitialised (internal-set lifecycle flag, not player-facing), and Permissions (PermissionSet). Everything else a player perceives as part of their character — money, inventory, needs, job — is deliberately not a Character field; it's owned by the module that models it and reached through that module's own [Sync] Component, per Character.cs's own header comment and 08_DATA_MODEL.md §1. CharacterTab reads those the normal way each already documented panel does — Name and Permissions direct from Character, job/faction from IJobService/TeamGroup (§4), nothing new needed for those.

Legacy's Details and Gender have no equivalent field anywhere in this codebase — confirmed against both Character.cs and its persisted counterpart, CharacterDocument (Code/Characters/CharacterStore.cs:267-273: Id, OwnerAccountId, Name, IsInitialised, Permissions — the same five, nothing more). Reaching legacy parity needs two new fields added to both types, plus a schema migration:

// Character.cs — new properties
public string Details { get; set; } = "";
public string Gender { get; set; } = "";

// CharacterStore.cs's CharacterDocument — mirrors the addition
public string Details { get; set; } = "";
public string Gender { get; set; } = "";

CharacterDocument is a persisted document (schemas.Register<CharacterDocument>(1), Code/Characters/CharacterModule.cs) — adding fields to it is a schema change, not a free edit, per 06_PERSISTENCE.md §4. This needs a version bump to 2 and an ISchemaMigration<CharacterDocument> from 1→2 defaulting both new fields to "" for every already-persisted character — the same small, chained migration step 06_PERSISTENCE.md §4 already describes, not a direct 1→3-style leap.

With those two fields in place, a new CharacterCommandsModule (Applejack.Characters, DependsOn: [CoreModule, CharacterModule, CommandsModule]) registers:

Command RequiredPermission Arguments Execute
/details <text...> null 1 Sets the caller's own Character.Details, persists via ICharacterService.SaveAsync
/gender <male\|female> null 1 Sets the caller's own Character.Gender, same persistence path

Corrected from this section's first draft, which called for CharacterModule itself to register these two commands directly. That doesn't actually compile: CommandsModule.DependsOn already includes CharacterModule (Code/Commands/CommandsModule.cs), so CharacterModule depending back on CommandsModule to register /details//gender would be a module dependency cycle. CharacterCommandsModule is a sibling module in the same Applejack.Characters namespace instead — the same shape AdminModule's /grant//revoke already use relative to Code/Characters/ (AdminModule depends on both CharacterModule and CommandsModule, and isn't part of either), just for a dependency-cycle reason here rather than AdminKickModule's test-exclusion reason. Caught while actually wiring the command, not during this document's original design pass — fixed in the same commit as the code that found it, per 00_CONSTITUTION.md.

Job/team change reuses the existing /team command (Code/Jobs/JobsModule.cs) — no new surface needed there; CharacterTab just lists IJobService's jobs grouped by TeamGroup (Officials/Civilians/Underground — the fixed three groups replacing legacy's open-ended faction list) and fires /team <jobAssetId> per "Become" button, exactly like DevMenuPanel.razor's existing Join button already does.

5.1 Milestone 13: multiple characters, biography, attributes, skills

CharacterTab gains a /biography <text...> self-service edit (CharacterCommandsModule, same shape as /details), and read-only Attributes/Skills display sourced from the active Character.Progression. Editing Attributes/Skills is deliberately not a player self-service command this milestone -- see 25_CHARACTER_CREATION_AND_PROGRESSION.md §11 for why (no consumer or growth-mechanic decision exists yet to validate free-typed values against); AdminModule gains /setattribute//setskill instead, gated by admin.moderate, until Milestone 16 gives these a real mechanic.

A new panel, CharacterSelectPanel (Code/Characters/Ui/CharacterSelectPanel.razor), is the real creation/selection/deletion screen ADR-0011 calls for -- distinct from CharacterTab, since it must be reachable before a connection has an active character at all, not just as a menu tab inside PlayerMenu (which itself currently has no consumer wired to open it automatically either -- see Code/Movement/PlayerSpawnerComponent.cs's own header comment). CharacterSelectRootComponent shows/hides it by polling CharacterNetworkComponent.IsInitialised each frame, following AdminToolsRootComponent's on-demand-panel precedent (§7) but reacting to session state instead of a keybind. See 25_CHARACTER_CREATION_AND_PROGRESSION.md §9-10 for the full shape and the new RequestSelectCharacter/RequestDeleteCharacter/ RequestReturnToSelection RPCs it drives.

6. Laws tab — no new backend surface

ILawService.GetLaws()/SetLawAsync (Code/Law/ILawService.cs) already model exactly what the tab needs, including the law.edit permission gate and the 120-second edit cooldown. The panel reads GetLaws() directly, host-side only — the same live-collection carve-out AdminPanel/DevMenuPanel already use for ICharacterService.GetAllActive() (11_UI_ARCHITECTURE.md §7) — and an edit box per slot fires the existing /setlaw <index> <text> command through CommandDispatchComponent. Open UX question, not solved here: the cooldown's remaining time isn't exposed anywhere a panel could read it without a new query surface, so a rejected edit (cooldown not yet elapsed) can show "edit failed," not "edit failed, N seconds left" — acceptable for a first pass, named so it isn't mistaken for an oversight later.

7. Info, Changelog, and Credits — one shared content resource

Legacy's [Welcome]/[Rules]/[Credits] tabs (cl_welcome.lua, cl_rules.lua, cl_credits.lua) and the changelog were all string-per-heading text blocks with no interactivity. Simplest option that avoids inventing a content-authoring system this project doesn't otherwise have: a single StaticContentConfiguration : GameResource (following CharacterConfiguration's own precedent) holding one string field per static tab, loaded once at boot, rendered read-only — no new module, sits in Code/Ui/ alongside the registries, since nothing else needs it:

namespace Applejack.Ui;

[GameResource("Static Content", "staticcontent", "Read-only text shown in the player menu's Info/Changelog/Credits tabs.")]
public sealed class StaticContentConfiguration : GameResource
{
    [Property] public string WelcomeText { get; set; } = "";
    [Property] public string RulesText { get; set; } = "";
    [Property] public string ChangelogText { get; set; } = "";
    [Property] public string CreditsText { get; set; } = "";
}

This is the smallest option that closes all three tabs, not a mandated design — open to a richer per-section format later if a flat string per tab proves awkward to author.

Info tab renders WelcomeText then RulesText, one after the other — legacy kept these as separate tabs; folding them costs nothing since both are static, and a player opening "Info" reads both in one place instead of hunting across two near-identical tabs.

8. Help tab — reuses ICommandRegistry as-is

ICommandRegistry.All (Code/Commands/ICommandRegistry.cs) already carries everything legacy's static Help tab had to hand-author: Name, Usage, Category, HelpText per registered command — CommandsModule's own /help proves this data is already complete enough to list (Code/Commands/CommandsModule.cs). HelpTab groups All by Category and renders Usage/HelpText per row — no new backend surface, and unlike a hand-written help page it can never drift out of date with what commands actually exist, since it reads the same registry /help itself reads.

9. Changelog tab

Renders StaticContentConfiguration.ChangelogText as-is (§7) — one entry per release, newline-separated, the same flat-text treatment as Info. No automated generation from git history or ROADMAP.md milestones is proposed here; keeping it a manually-authored field avoids coupling player-facing copy to commit-message wording, which is written for a different audience.

10. Credits tab

Renders StaticContentConfiguration.CreditsText (§7). Content, not code — authored directly into the asset, not hardcoded into a panel — but recorded here since it was specified explicitly rather than left as a placeholder:

[Original Applejack]
kuromeku — original creator of Applejack (Cider)
Lexi — further development

[Applejack — S&box rewrite]
fobiat — rewrite and modernisation (fobiat.github.io)

[Engine]
Built on S&box by Facepunch Studios

This mirrors README.md's own Credits section, which carries the same attribution — the two should be kept in sync by hand going forward, same as any other duplicated fact this codebase doesn't have a single source of truth for yet.

11. Reactivity

Every new tab follows 11_UI_ARCHITECTURE.md §2's rule: BuildHash() includes every value the markup reads. CharacterTab combines the caller's own Character state with IJobService.All.Count; LawsTab hashes GetLaws()'s ten slots; HelpTab hashes ICommandRegistry.All.Count (registration is boot-time-only today, so this never actually changes post-boot, but costs nothing to include); InfoTab/ChangelogTab/ CreditsTab each return a constant (static content loaded once, never rebuilt — same reasoning HudRoot.razor's own BuildHash() => 0 already documents for static structure).

12. Unverified engine assumptions (named, not faked)

Same practice as ROADMAP.md § Verification debt and 22_PLAYER_PRESENCE_AND_ADMIN_TOOLS.md §5:

  • @ref panel-reference capture and Panel.DeleteChildren() (§3).
  • Whether onclick="@(() => Select(tab.Title))" correctly closes over a loop variable across Razor's own diffing — AdminPanel.razor's per-character button loop makes the same unverified assumption already.
  • Whether swapping the player-menu-content container's child on tab select, rather than toggling pre-built children's visibility (HudRoot's approach), causes any per-frame cost or flicker — untested without a running client.

13. Testing

MenuRegistry's bookkeeping is trivially correct, same as HudRegistry's (Code/Ui/README.md's own "Not unit-tested" reasoning applies identically here — a where TPanel : Panel, new() constraint needs a real engine type to even call it). CharacterTab's job-grouping-by-TeamGroup logic, LawsTab's slot-formatting logic, and HelpTab's grouping-by-Category logic should each be extractable into a plain method taking data and returning a view model, per 11_UI_ARCHITECTURE.md §6 — unit-tested the same way the rest of this codebase's engine-agnostic core already is. The new Character.Details/Gender schema migration (§5) gets its own test, following CharacterStore's existing migration test pattern for schema version 1's own fields.