Skip to content

UI Architecture

How a panel gets built and registered, and the boundary that keeps UI from becoming another ply.cider — a place eleven systems reach into because it's convenient.


Ownership

UI owns no gameplay state. A panel reads other modules exclusively through their public service interfaces and subscribes to their events, exactly like any other consumer (02_MODULE_MODEL.md §5) — it has no special access. This is why Code/Ui/ is listed as a module folder in 01_PROJECT_LAYOUT.md but treated as architecturally distinct: every other module provides state; UI only reads it.

"Reads through a public service interface" describes layering, not the wire. A service (IEconomyService, IInventoryService, ...) is resolved through the module system, which is only guaranteed to be booted where the host's own scene startup runs (02_MODULE_MODEL.md §4) — a panel running on a remote client has no path to call it directly. What actually crosses the network, per 07_NETWORKING.md rule 4, is a [Sync] property on a Component that the host populates by calling the service. A panel binds to that Component, not to the service — the service's public shape is what the Component's replicated state mirrors, not something a panel ever reaches past the Component to call itself.

1. Engine primitive

S&box UI is built with Razor panels — HTML/CSS-like markup compiled with C#, rendered directly by the engine rather than through an HTML renderer (Razor Panels):

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

<root>
    <div class="inventory-grid">
        @foreach (var item in Inventory.Contents)
        {
            <ItemSlot Instance="@item" />
        }
    </div>
</root>

@code {
    [Property] public IInventory Inventory { get; set; } = null!;
}

Every UI tree has exactly one PanelComponent at its root, attached to a GameObject that also carries a ScreenPanel (2D, screen-space) or WorldPanel (3D, in-world — a sign above a door, a nameplate) component. A PanelComponent cannot be nested inside another panel's markup — composition happens through ordinary child Panel types (<ItemSlot /> above), not nested roots.

No Derma ports. The legacy derma/*.lua VGUI panels are read for behaviour only, never for implementation pattern — 00_CONSTITUTION.md §1 draws this line explicitly: "the inventory grid shows N columns and highlights the hovered slot" is gameplay, worth preserving; "here is how a Derma panel's Paint hook is structured" is implementation, owed nothing.

2. Reactivity

A panel's markup rebuilds only when BuildHash() changes — every value the markup reads must be included in that hash, or the panel goes stale silently. A panel bound to IInventoryService.GetContents(characterId) includes the inventory's version or contents hash in BuildHash(), not just calls the getter and hopes:

protected override int BuildHash() => System.HashCode.Combine(Inventory.Contents.Count, Inventory.Version);

This is the direct replacement for the original's client-side cider.inventory.stored rebuild-from-deltas pattern (D-05) — the panel reads from [Sync]-replicated, host-authoritative state (07_NETWORKING.md), which converges on its own; the panel's only job is noticing when it changed.

3. Registration

Panels that represent a persistent piece of HUD (a hunger bar, a money display) are not manually instantiated by gameplay code — they are registered against a small IHudRegistry that a top-level HUD root queries, so a module can contribute a HUD element without the HUD root knowing that module exists:

namespace Applejack.Ui;

public interface IHudRegistry
{
    /// <summary>Registers a HUD panel type to be instantiated once the local player's HUD builds.</summary>
    void RegisterHudElement<TPanel>(HudSlot slot) where TPanel : Panel, new();
}
// In NeedsModule.Ready:
services.GetRequiredService<IHudRegistry>().RegisterHudElement<HungerBar>(HudSlot.StatusBottomLeft);

Menus, dialogs, and other on-demand UI are opened directly by the code that needs them (an interaction's completion opening a lockpicking minigame panel) — registration is for standing HUD elements specifically, where "which modules currently exist" would otherwise leak into a central HUD file.

Instantiation. The HUD root does not resolve TPanel back from a bare Type at build time (TypeLibrary.Create<Panel>(Type) is a real compiler whitelist violation against the dedicated server — reflection addon code isn't allowed to call). Instead, RegisterHudElement<TPanel>'s own implementation builds a Func<Panel> CreatePanel = () => new TPanel(); closure right there, where TPanel is still compile-time-known at that generic call site — no runtime reflection anywhere in the path. The HUD root then just calls element.CreatePanel(). See Code/Ui/HudElementRegistration.cs's header comment and Code/Commands/Ui/HelpTab.razor for a converted, working example (IMenuRegistry's own RegisterMenuTab<TPanel> mirrors this exactly).

4. Client-side checks are advisory only

A panel may grey out a "Buy" button because the character can't afford a door, or hide a context menu option the player lacks permission for. That check is never the actual gate — per 07_NETWORKING.md, the host re-validates everything when the [Rpc.Host] call arrives regardless of what the UI showed. A panel's validation logic exists purely to avoid a round trip for an action the player obviously cannot take; it must reuse the same engine-agnostic rule types the host validates against (Standards/00_CODING_STANDARDS.md §7), not a separately-written approximation that can drift from the real rule.

5. Pending states

Because every meaningful action is a round trip (07_NETWORKING.md §10 — "every client action costs a round trip"), a panel that fires an RPC shows a pending state (a disabled button, a spinner) until the corresponding [Sync] state changes or a result RPC arrives, rather than assuming success and updating optimistically. Optimistic UI here would visibly desync the moment a host-side validation rejects the action — the original's dropped-message desync (D-05) is exactly this failure mode, just triggered by a different cause.

6. Testing

UI logic that decides what to show given a state (which button is enabled, what a tooltip says) should be extractable into a plain method taking data and returning a view model, so it is unit-testable without a Scene — the same engine-agnostic-core discipline as everywhere else (Standards/03_TESTING_STANDARDS.md §1). Rendering itself is verified manually per the multiplayer and manual-testing checklist in Standards/03_TESTING_STANDARDS.md §5.

7. On-demand panels (Milestone 10)

§3 deliberately scopes IHudRegistry to standing elements and says on-demand UI (menus, dialogs) is "opened directly by the code that needs them" — until Milestone 10's admin panel and dev menu, nothing had actually needed one, so this was unwritten. The pattern, established by Code/Admin/Ui/AdminToolsRootComponent.cs:

  • A plain Component, placed directly in the scene rather than registered against any registry — the same "unverified scene wiring, same caveat every Component already carries" status HudRoot itself already has (Code/Ui/README.md).
  • Checks Input.Pressed("<action>") once per frame and toggles a child panel's visibility. One action name per panel (admin_panel, dev_menu) — a config-driven rebindable keybind is real scope for later, not invented here; the action names themselves are the only thing an operator needs to remap through S&box's own input-binding settings.
  • Gates the toggle on the caller's own permission (character.HasPermission(...)), exactly like §4's "grey out a button" — this is UX only, never the real gate. The panel's own buttons still fire ordinary commands through CommandDispatchComponent.RequestExecuteCommand, which the host re-validates regardless of whether the client should have been able to see the button that sent it.
  • Unlike a standing HUD element, an on-demand panel is free to read live, non-replicated server-side collections directly where a [Sync] mirror would be overkill for something an admin opens rarely (e.g. ICharacterService.GetAllActive() for a player list) — this only holds host-side, where the panel's own Component already runs with full service access; a remote client's copy of the same panel still only ever sees what's [Sync]-replicated to it, per the Ownership section above. An on-demand admin panel that needs the same view on a dedicated server's remote clients would need its own replicated snapshot the same way InventorySlotView mirrors IInventory — not needed yet since Milestone 10's admin/dev tools are reviewed by reading, not run on a real dedicated server this pass.