Menu System
The menu system is a component-based framework for per-player menus. A menu is an arrangement of components (APIIMenuComponent) drawn by a renderer (APIIMenuRenderer) and driven by a keymap, through Core.Menu (APIISwiftlyCore.Menu, type APIIMenuService).
The menu system is available on the beta branch only. It replaces the
classic Core.MenusAPI, which is now obsolete and documented on the
Menus page.
Components describe what to show and how they react to actions; renderers decide how it looks. A menu opened as centre-screen HTML (the default) can be switched to chat lines by the server owner without any plugin change.
Quick Start
using SwiftlyS2.Shared.Menu;
using SwiftlyS2.Shared.Menu.Components;
var menu = Core.Menu.CreateMenu("myplugin.settings")
.WithTitle("Settings")
.Add(new ButtonComponent("Say hello", ctx =>
{
ctx.Player.SendChat("Hello!");
return ValueTask.CompletedTask;
}))
.Add(new ToggleComponent("Show damage", defaultValue: true, onChanged: change =>
{
change.Player.SendChat($"Show damage: {change.NewValue}");
}))
.Build();
menu.Open(player);Core.Menu.CreateMenu(id)starts an APIIMenuBuilder.- Configure it (
With*methods) and add components to the header, body or footer. Build()returns the APIIMenu. Open it withmenu.Open(player)ormenu.OpenForAll().
The menu id is also its configuration scope for key binds, renderer and input method (see Server Configuration). Namespace it with your plugin id, such as "myplugin.shop".
The Menu Service
APIIMenuService is your plugin's entry point. Everything you create or register through it belongs to your plugin and is released when the plugin unloads, so one plugin unloading never disturbs another plugin's menus.
| Member | Description |
|---|---|
APIIMenuService.CreateMenu(string id) | Starts building a menu. |
APIIMenuService.GetSession(IPlayer player) | The APIIMenuSession of whatever menu the player has open, or null. Spans every plugin. |
APIIMenuService.CloseFor(IPlayer player) | Closes the player's open menu, only when it belongs to your plugin. |
| APIIMenuService.CloseAll | Closes every open menu that belongs to your plugin. |
| APIIMenuService.Actions | The APIIMenuActionRegistry: the catalogue of bindable actions. |
| APIIMenuService.Renderers | The APIIMenuRendererRegistry: the catalogue of renderers and per-renderer component drawing. |
A player has at most one open menu at a time, across every plugin. Opening a menu replaces the one they had.
Building a Menu
Every APIIMenuBuilder method returns the builder, except Build():
| Method | Description |
|---|---|
APIIMenuBuilder.WithTitle(string title) | Adds a title line to the header. |
APIIMenuBuilder.AddHeader(IMenuComponent) | Appends a component to the header (pinned above the body, never scrolls, never takes focus). |
APIIMenuBuilder.Add(IMenuComponent) | Appends a component to the scrollable body. |
APIIMenuBuilder.AddFooter(IMenuComponent) | Appends a component to the footer (pinned below the body, never scrolls, never takes focus). |
APIIMenuBuilder.WithParent(IMenu parent) | Sets the menu returned to when this one closes. |
APIIMenuBuilder.WithItemsPerPage(int itemsPerPage) | Largest number of body entries shown at once (minimum 1, default 5). See the note below. |
APIIMenuBuilder.WithAutoClose(TimeSpan? delay) | Closes the session after the delay, counted from when it opens (not from the last input). null disables it, which is the default. |
| APIIMenuBuilder.WithAction(...) | Declares an extra key bind for this menu, see Actions and Key Binds. |
APIIMenuBuilder.WithRenderer(string rendererId) | Chooses the renderer. Defaults to MenuRendererIds.CenterHtml. Build() throws when the id is not registered. |
APIIMenuBuilder.WithInputMethod(MenuInputMethod) | Chooses how players interact with the menu, see Input Methods. |
| APIIMenuBuilder.WithKeybindSource / APIIMenuBuilder.WithRendererSource / APIIMenuBuilder.WithInputMethodSource | Adds a source of overrides, for enforcing a choice from your plugin's own configuration. The server-wide file still wins. |
| APIIMenuBuilder.Build | Builds and returns the APIIMenu. |
WithItemsPerPage is an upper bound, not a guarantee. The server-wide
MaxVisibleLines limit counts rendered lines, so a spacer or a two-line
component leaves room for fewer neighbours. This keeps the last entries of a
long menu reachable instead of pushing them off screen.
Only components with IsFocusable == true in the body can hold the selection. Non-focusable body entries (dividers, spacers, progress bars) scroll past.
Components
All built-in components live in SwiftlyS2.Shared.Menu.Components.
| Component | Focusable | Description |
|---|---|---|
APITextComponent(text) | yes | A line of text. Does nothing when activated. |
APITitleComponent(title) | no | The title line (what WithTitle adds). |
APIButtonComponent(text, onSelect) | yes | Runs onSelect when activated. |
APIToggleComponent(text, defaultValue, onChanged) | yes | Per-player on/off value. |
APISliderComponent(text, min, max, defaultValue, step, onChanged) | yes | Per-player number drawn as a bar. Left/Right step it. |
APIChoiceComponent(text, choices, defaultIndex, onItemChanged) | yes | Cycles forward through a list on each activation. Also generic: ChoiceComponent<TItem>. |
APISelectorComponent`1(text, choices, defaultIndex, onItemChanged) | yes | Steps through a list with Left/Right and shows the neighbouring items. |
APIInputComponent(text, defaultValue, validator, onChanged) | yes | Takes the player's next chat message as its value. |
APIProgressBarComponent(text, progressProvider, multiLine) | no | Bar for a value something else owns (0 to 1). Redraws itself when the value moves. |
APISubmenuComponent(text, IMenu or factory) | yes | Opens another menu. |
APIChatColoredTextComponent(text, chatColor) | yes | Text coloured with a Helper.ChatColors tag. On the centre-HTML renderer it maps to the closest hex colour. |
APIDividerComponent(length, color) | no | A horizontal rule drawn from ─ characters. |
APISpacerComponent(lines) | no | Blank vertical space. |
Every constructor also takes an optional trailing id (a stable id, generated when omitted).
Common Properties
These come from APIMenuComponentBase and apply to every built-in component:
| Property | Description |
|---|---|
Visible / Enabled | Show or enable the component for every player. |
VisibleWhen / EnabledWhen | Func<IMenuSession, bool> deciding it per player, evaluated every frame. When set, combined with Visible/Enabled. |
Comment | Hint shown in the footer while the component holds the selection. |
PlaySound | Whether activating the component plays the selection sound (default true). |
Validating | Func<MenuActionContext, ValueTask<bool>> that runs before an activation; return false to cancel it. |
WaitingText / WaitingColor | What is drawn while an async activation is still running (default "Waiting..."). The same player cannot trigger it twice meanwhile. |
Tag | Free-form object for your own bookkeeping. |
Text-based components (APITextComponent and everything derived from it) also have Text, TextProvider (Func<IMenuSession, string?> for per-player text), Style (APIMenuTextStyle), DisabledColor, MaxWidth and Overflow. Overflow is a APIMenuTextOverflow: None, TruncateEnd, TruncateBothEnds, ScrollLeftFade, ScrollRightFade, ScrollLeftLoop or ScrollRightLoop.
Colours are hex strings such as "#FFD700".
Buttons
var close = new ButtonComponent("Apply and close", async ctx =>
{
await SaveAsync(ctx.Player);
ctx.Player.SendChat("Saved.");
})
{
CloseOnSelect = true,
FocusedColor = "#FFD700"
};OnSelect is asynchronous: while it runs, the button draws WaitingText and ignores further presses from the same player. Set CloseOnSelect to close the menu once it finishes.
Per-Player Values
Value components (toggle, slider, choice, selector, input) keep their value in the player's session, so one component instance serves every player without their values touching. The value is gone when the menu closes: persist it in OnChanged and read it back with DefaultValueProvider.
var volume = new SliderComponent("Volume", min: 0, max: 100, defaultValue: 50, step: 10)
{
TotalBars = 10,
DefaultValueProvider = session => LoadVolume(session.Player),
OnChanged = change => SaveVolume(change.Player, change.NewValue)
};
var quality = new ChoiceComponent("Quality", ["Low", "Medium", "High"], defaultIndex: 1,
onItemChanged: change => Core.Logger.LogInformation("{Old} -> {New}", change.OldValue, change.NewValue));
var team = new SelectorComponent<string>("Team", ["T", "CT", "Spectator"]);You can also read and write a player's value directly:
float current = volume.GetValue(session);
volume.SetValue(session, 80f); // returns true when the value actually changedOnChanged receives a APIMenuValueChangedContext`1 with Session, Component, OldValue, NewValue and Player. For choice and selector components the underlying value is the index (OnChanged gets int), while OnItemChanged gets the item itself; GetItem(session) reads the current item.
Input
var nickname = new InputComponent(
"Nickname",
defaultValue: "",
validator: value => value.Length >= 3,
onChanged: change => change.Player.SendChat($"Nickname set to {change.NewValue}"))
{
MaxLength = 24
};Activating the component captures that player's next chat message as the value. The message never reaches the chat, and activating again while waiting cancels. Rejected values show InvalidText. Empty input is always rejected.
Progress Bars
var bar = new ProgressBarComponent(
"Round progress",
session => RoundProgress(), // 0..1
multiLine: true);The bar polls ProgressProvider and redraws on its own whenever the number of filled segments changes, so you do not need to invalidate anything.
Submenus
var advanced = Core.Menu.CreateMenu("myplugin.advanced")
.WithTitle("Advanced")
.Add(new TextComponent("Nothing here yet"))
.Build();
var main = Core.Menu.CreateMenu("myplugin.main")
.WithTitle("Main")
.Add(new SubmenuComponent("Advanced", advanced))
.Add(new SubmenuComponent("Built on demand", session =>
Core.Menu.CreateMenu("myplugin.lazy").WithTitle("Lazy").Build()))
.Build();A submenu without a parent of its own adopts the menu that opened it, so closing it returns the player there. The factory overloads (sync and ValueTask) can return null to cancel the open.
Dynamic Components
Components can be added to or removed from an open menu. Sessions redraw automatically.
var online = new TextComponent { TextProvider = _ => $"Players online: {Core.PlayerManager.PlayerCount}" };
menu.Insert(MenuRegion.Body, 0, online);
menu.Add(MenuRegion.Footer, new DividerComponent());
menu.Remove(online);
var body = menu.GetComponents(MenuRegion.Body);Actions and Key Binds
Navigation is built from actions, each bound to a APIMenuKey. Every menu inherits the built-in actions from APIMenuActions:
| Action | Default key | Effect |
|---|---|---|
MenuActions.NavigateUp | Shift | Moves the selection up (wraps around). |
MenuActions.NavigateDown | F | Moves the selection down (wraps around). |
MenuActions.NavigateLeft | A | Does nothing on its own. Sliders and selectors take it over while focused. |
MenuActions.NavigateRight | D | Does nothing on its own. Sliders and selectors take it over while focused. |
MenuActions.Select | E | Activates the focused component. |
MenuActions.Close | Tab | Closes the menu, returning to its parent if it has one. |
APIMenuKey is a [Flags] enum, so one action can use several keys: MenuKey.E | MenuKey.Mouse1. Available keys: Mouse1, Mouse2, Space, Ctrl, W, A, S, D, E, Esc, R, Alt, Shift, Weapon1, Weapon2, Grenade1, Grenade2, Tab, F.
Custom Actions
WithAction declares an extra key for one menu. With a handler it runs regardless of which row is selected:
var menu = Core.Menu.CreateMenu("myplugin.shop")
.WithTitle("Shop")
.WithAction("Refresh", MenuKey.R, async ctx =>
{
await ReloadStockAsync();
ctx.Session.Invalidate();
}, label: "Reload")
.Add(new ButtonComponent("Buy"))
.Build();- The key shows in the footer as
label: KEY(the name is used whenlabelisnull). - Declaring an action with the name of a built-in one (for example
"Close") rebinds it for this menu only. - The focused component is offered every action first (APIIMenuComponent.HandleActionAsync). The menu-level handler only runs when it does not consume the key.
- For full control use the APIMenuActionDescriptor overload (
Id,DefaultKey,Label,ShowInFooter,Order).
Handlers receive a APIMenuActionContext with Action, Key, Session, Player and Menu.
Key Resolution
For each action the key is taken from the first source that answers, highest priority first:
| Priority | Source |
|---|---|
MenuKeybindPriority.Global (100) | The server-wide menus.jsonc. |
MenuKeybindPriority.Plugin (50) | Sources you add with WithKeybindSource, for example from plugin config. |
MenuKeybindPriority.CodeDefault (0) | The DefaultKey declared on the action. |
Renderer and input method resolve the same way (APIIMenuRendererSource, APIIMenuInputMethodSource).
Input Methods
APIMenuInputMethod decides how players interact:
Buttons- keyboard keys bound to actions, as above.Chat- players type!1,!2, ... to select the entry with that number on the current page, and!0to close. Keyboard keys are ignored for that menu, and matching messages never reach the chat.
If you do not set one, menus drawn by the chat renderer use Chat and every other menu uses Buttons.
Renderers
Id (MenuRendererIds) | Description |
|---|---|
CenterHtml ("centerhtml") | Centre-screen HTML. The default. |
Chat ("chat") | A block of chat lines. |
Custom id ("myplugin.plain") | A renderer registered by a plugin, see Custom Renderers. |
var menu = Core.Menu.CreateMenu("myplugin.info")
.WithRenderer(MenuRendererIds.Chat)
.WithTitle("Info")
.Build();Components produce a tree of APIMenuNode values (MenuTextNode, MenuLineNode, MenuStackNode, MenuBlankNode, MenuSelectionNode, MenuRawNode) instead of markup, which is why every component works on every renderer. A plugin can add its own renderer, see Custom Renderers.
Custom Renderers
A renderer turns a composed frame into output and delivers it to the player. It never looks at concrete component types, only at APIMenuNode trees, so a custom renderer can draw every built-in component and every custom one.
Implementing IMenuRenderer
APIIMenuRenderer has an Id, a Render method and a Clear method:
using SwiftlyS2.Shared.Menu;
using SwiftlyS2.Shared.Players;
public sealed class PlainChatRenderer : IMenuRenderer
{
public string Id => "myplugin.plain";
public void Render(IMenuRenderContext context)
{
var frame = context.Frame;
var player = context.Player;
foreach (var node in frame.Header.Concat(frame.Body).Concat(frame.Footer))
{
foreach (var line in Flatten(node, context))
{
player.SendChat(line);
}
}
}
public void Clear(IPlayer player)
{
// Nothing persistent on screen, so nothing to remove.
}
private static IEnumerable<string> Flatten(MenuNode node, IMenuRenderContext context)
{
switch (node)
{
case MenuTextNode text:
yield return text.Text;
break;
case MenuLineNode line:
yield return string.Concat(line.Children.SelectMany(c => Flatten(c, context)));
break;
case MenuStackNode stack:
foreach (var line in stack.Children.SelectMany(c => Flatten(c, context)))
{
yield return line;
}
break;
case MenuBlankNode blank:
for (var i = 0; i < blank.Lines; i++)
{
yield return " ";
}
break;
case MenuSelectionNode selection:
yield return selection.Focused ? ">" : " ";
break;
case MenuRawNode raw when raw.RendererId == "myplugin.plain":
yield return raw.Payload;
break;
case MenuRawNode:
break; // aimed at another renderer, ignore it
default:
context.ReportUnsupported(node);
break;
}
}
}Register it from your plugin, then select it by id like any other renderer:
public override void Load(bool hotReload)
{
_renderer = Core.Menu.Renderers.Register(new PlainChatRenderer());
}
public override void Unload()
{
_renderer?.Dispose(); // optional, released on unload anyway
}
var menu = Core.Menu.CreateMenu("myplugin.info")
.WithRenderer("myplugin.plain")
.WithTitle("Info")
.Build();Registerreturns anIDisposable; disposing it removes the renderer. Registering an id that already exists replaces the old renderer.- Registrations made through
Core.Menuare released when your plugin unloads. - Use a plugin-namespaced id (
"myplugin.plain") so it never clashes with another plugin or withMenuRendererIds. - A menu that asks for an unregistered id fails in
Build(). The server owner can also force your renderer on any menu with"Renderer": "myplugin.plain"inmenus.jsonc.
IMenuRenderer.Render runs on a dedicated background thread, not the game
thread. Use only the data in the render context and calls that are safe off
the game thread, such as sending text to the client. Never reach back into
live player, controller or pawn state. Clear runs on the game thread.
The Render Context
APIIMenuRenderContext carries everything a frame needs:
| Member | Description |
|---|---|
Frame | The composed APIMenuFrame: Header, Body and Footer node lists. |
Menu, Session | The menu and the player's session being drawn. |
Player | The player the frame is for. |
ReportUnsupported(node) | Reports a node you cannot draw. Deduplicated per renderer and node type, so it is safe to call every frame. |
Header, Body and Footer
A frame is split into three regions. The split is made by the menu, never by the renderer: components are placed in a region when added (AddHeader / WithTitle, Add, AddFooter, or MenuRegion with menu.Add / menu.Insert), and the renderer receives one node list per region.
| Region | Frame property | Behaviour |
|---|---|---|
| Header | Header | Pinned above the body. Never scrolls, never takes focus. Holds the title by default. |
| Body | Body | The scrolling part. Already reduced to the visible page. Focusable entries carry a MenuSelectionNode marker slot. |
| Footer | Footer | Pinned below the body. Never scrolls, never takes focus. Holds action key hints and the focused component's Comment. |
- Draw the regions top to bottom: header, body, footer. Keep the header and footer visible on every page.
- Only the body is paged.
MaxVisibleLinesandWithItemsPerPagelimit body entries, so header and footer lines are extra. - A region can be empty. Draw nothing for it, not a blank gap.
- Visibility is already applied: components hidden by
Visible/VisibleWhenare absent from the lists.
By the time Render is called, focus, paging and visibility are already resolved. Frame.Body holds only the visible page, Frame.PageOffset is the index of its first entry and Frame.TotalItems the number of focusable entries across all pages, which is enough to draw a page indicator.
Node Types
| Node | Meaning |
|---|---|
| APIMenuTextNode | A run of text with a APIMenuTextStyle (Color as hex, Bold, Size). |
| APIMenuLineNode | Children laid out horizontally on one line. |
| APIMenuStackNode | Children stacked vertically, one per line. |
| APIMenuBlankNode | Lines of empty vertical space. |
| APIMenuSelectionNode | The selection marker slot of a body entry. Focused says if it holds the selection, Number is the !n value under Chat input (0 when not numbered). The renderer chooses the indicator. |
| APIMenuRawNode | Payload aimed at one RendererId. Ignore raw nodes addressed to another renderer. |
Rules for a well-behaved renderer:
- Skip a node type you do not understand and call
ReportUnsupported. Never throw, since new node types can appear. - Map
MenuTextSize(ExtraSmalltoExtraLarge) and colours onto whatever your output supports, or ignore them. - Pair your renderer with an input method: menus drawn by the
chatrenderer default toMenuInputMethod.Chat, every other renderer defaults toButtons. If your output shows!1,!2, ... numbers, callWithInputMethod(MenuInputMethod.Chat)on the menu, or add aWithInputMethodSource. - Frames are sent only when a session is invalidated, so a renderer does not need its own refresh loop.
- Remove anything you keep on screen in
Clear. It runs when the session closes.
Component Renderers
Components draw themselves through Render. To give one component type richer output on one particular renderer, implement APIIComponentRenderer and register it:
public sealed class SliderPlainRenderer : IComponentRenderer
{
public string RendererId => "myplugin.plain";
public Type ComponentType => typeof(SliderComponent); // derived types match too
public bool TryRender(IMenuComponent component, IMenuComponentRenderContext context, out MenuNode? node)
{
var slider = (SliderComponent)component;
node = new MenuTextNode($"{slider.Text}: {slider.GetValue(context.Session)}");
return true;
}
}
_componentRenderer = Core.Menu.Renderers.RegisterComponentRenderer(new SliderPlainRenderer());- Return
falsefromTryRenderto fall back to the component's ownRender. RendererIdcan be a built-in one (MenuRendererIds.Chat) to change how a component looks there. This is how a plugin teaches an existing renderer about a component it has never seen.- APIIMenuComponentRenderContext gives
Component,Menu,Session,Player,IsFocused,IsEnabledandRendererId. - Without a matching component renderer the component's own output is used, so an unrecognised component is never an error.
- Like renderers, component renderers are released when your plugin unloads.
To target a renderer from inside a custom component instead, check context.RendererId in Render and return a MenuRawNode. Always return usable generic nodes when the id is not one you know.
Menus, Sessions and Events
APIIMenu is the shared definition; APIIMenuSession is one player's live view of it.
| APIIMenu member | Description |
|---|---|
Id, Renderer, Keymap, InputMethod, ItemsPerPage, AutoCloseDelay | What the menu was built with (resolved after server-side overrides). |
Parent | The menu to return to on close. Writable, so lazily built submenus can be told. |
Tag | Free-form object for your own bookkeeping. |
Sessions / GetSession(player) | Sessions currently viewing the menu. |
Open(player) / OpenForAll() | Opens for one player, or for every connected non-bot player. Returns the sessions. |
Close(player) / CloseAll() | Closes for one player or everyone viewing it. |
GetComponents(region), Add, Insert, Remove | Reads and edits components, see Dynamic Components. |
Opened, Closed, FocusChanged | event Action<IMenuSession>. |
IMenu is IDisposable; disposing closes it for everyone and clears its components.
| APIIMenuSession member | Description |
|---|---|
Player, Menu, IsOpen | Who is viewing what, and whether it is still open. |
FocusedIndex, FocusedComponent | The selection, counted among visible focusable body components. |
MoveFocus(delta), SetFocus(index) | Moves the selection. MoveFocus wraps at both ends. Both return true when the selection changed. |
GetState<T>(component) / SetState | Per-player state for a component (T needs a parameterless constructor), created on first use. |
CaptureChat(onMessage) | Routes the player's next chat messages to onMessage. Return true to swallow the message. Dispose the handle to stop. Dropped when the menu closes. |
Invalidate() | Requests a redraw on the next tick. |
Close() | Closes the session, returning to the parent menu if there is one. |
Frames are only sent when a session is invalidated (or a visible component
reports NeedsRedraw). After changing anything that affects what a player
sees, call session.Invalidate(). Adding, inserting or removing components
does it for you.
menu.Opened += session => Core.Logger.LogInformation("{Player} opened {Menu}", session.Player.PlayerID, session.Menu.Id);
menu.Closed += session => SaveSettings(session.Player);
menu.FocusChanged += session => { /* selection moved */ };Writing a Custom Component
Derive from APIMenuComponentBase and override Render. Keep per-player data in session state, never on the component, so one instance can be shared across menus and players.
public sealed class CounterComponent : MenuComponentBase
{
public override bool IsFocusable => true;
public override MenuNode Render(IMenuComponentRenderContext context)
{
var count = context.Session.GetState<CounterState>(this).Count;
var color = context.IsFocused ? "#FFD700" : "#FFFFFF";
return new MenuTextNode($"Clicks: {count}", MenuTextStyle.Default.WithColor(color));
}
public override ValueTask<bool> HandleActionAsync(MenuActionContext context)
{
if (!Matches(context, MenuActions.Select))
{
return ValueTask.FromResult(false); // not ours, let the menu handle it
}
return ActivateAsync(context, ctx =>
{
ctx.Session.GetState<CounterState>(this).Count++;
ctx.Session.Invalidate();
return ValueTask.CompletedTask;
});
}
private sealed class CounterState
{
public int Count { get; set; }
}
}- Return
truefromHandleActionAsyncto consume the action; returnfalseto let the menu navigate or close as usual. ActivateAsyncruns your work through the shared pipeline: it honoursEnabled,Validatingand the busy/waiting state.- Override
LineCountwhenRenderreturns more than one line (aMenuStackNodecosts the sum of its children, aMenuBlankNodeitsLines). Otherwise the page is budgeted for one line and overflows. - Override
GetHintto show a hint while focused, andNeedsRedraw(session, now)for animated or live content (asked once per tick for every visible component, so keep it cheap). - Return a
MenuLineNodeto put several styled parts on one line, and aMenuRawNodeto aim output at one specific renderer id.
Server Configuration
Server owners tune menus in configs/menus/menus.jsonc. The file is optional and reloaded live; configs/menus/menus.example.jsonc ships as a template:
{
"Menus": {
// "Renderer": "centerhtml", // for every menu
// "InputMethod": "buttons", // for every menu
"core.menu": {
"NavigateUp": "shift",
"NavigateDown": "f",
"NavigateLeft": "a",
"NavigateRight": "d",
"Select": "e",
"Close": "tab"
}
// , "myplugin.shop": { "Renderer": "chat", "Select": ["e", "space"], "Close": "esc" }
},
"MenuLayout": { "MaxVisibleLines": 5 },
"MenuSounds": {
"Scroll": { "Name": "UI.ContractType", "Volume": 0.75 },
"Select": { "Name": "Vote.Cast.Yes", "Volume": 0.75 },
"Exit": { "Name": "Vote.Failed", "Volume": 0.75 },
"Fail": { "Name": "Vote.Cast.No", "Volume": 0.75 }
}
}| Key | Description |
|---|---|
Menus.core.menu.<Action> | Key binds every menu inherits. A value is a key name or an array of key names. |
Menus.<menuId>.<Action> | Overrides one action for the menu with that id. |
Menus.<menuId>.Renderer / InputMethod | Forces a renderer or input method for one menu. Directly under Menus it applies to every menu. The menu's own value wins. They override what plugins request in code. |
MenuLayout.MaxVisibleLines | Cap on how many lines the scrolling body draws at once. Clamped to 1-32, default 5. |
MenuSounds.<Scroll|Select|Exit|Fail> | Sound Name and Volume (default 0.75). An empty Name silences it. |
Key binds only apply to menus using the Buttons input method. An unregistered Renderer id makes that menu fail to build.
Common Pitfalls
- Give every menu a unique, plugin-namespaced id, or server key binds for one menu will apply to another.
- Do not store per-player data on a component; use session state or
OnChanged/DefaultValueProvider. - Call
session.Invalidate()after changing anything displayed from outside a component. - Declare
LineCounton custom multi-line components. - Keep
NeedsRedrawandTextProvidercheap; they run every tick/frame.