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" statusHudRootitself 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 throughCommandDispatchComponent.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 wayInventorySlotViewmirrorsIInventory— not needed yet since Milestone 10's admin/dev tools are reviewed by reading, not run on a real dedicated server this pass.