swiftlys2/docs/api/ai/

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);
  1. Core.Menu.CreateMenu(id) starts an APIIMenuBuilder.
  2. Configure it (With* methods) and add components to the header, body or footer.
  3. Build() returns the APIIMenu. Open it with menu.Open(player) or menu.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.

MemberDescription
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.CloseAllCloses every open menu that belongs to your plugin.
APIIMenuService.ActionsThe APIIMenuActionRegistry: the catalogue of bindable actions.
APIIMenuService.RenderersThe 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():

MethodDescription
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.WithInputMethodSourceAdds a source of overrides, for enforcing a choice from your plugin's own configuration. The server-wide file still wins.
APIIMenuBuilder.BuildBuilds 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.

ComponentFocusableDescription
APITextComponent(text)yesA line of text. Does nothing when activated.
APITitleComponent(title)noThe title line (what WithTitle adds).
APIButtonComponent(text, onSelect)yesRuns onSelect when activated.
APIToggleComponent(text, defaultValue, onChanged)yesPer-player on/off value.
APISliderComponent(text, min, max, defaultValue, step, onChanged)yesPer-player number drawn as a bar. Left/Right step it.
APIChoiceComponent(text, choices, defaultIndex, onItemChanged)yesCycles forward through a list on each activation. Also generic: ChoiceComponent<TItem>.
APISelectorComponent`1(text, choices, defaultIndex, onItemChanged)yesSteps through a list with Left/Right and shows the neighbouring items.
APIInputComponent(text, defaultValue, validator, onChanged)yesTakes the player's next chat message as its value.
APIProgressBarComponent(text, progressProvider, multiLine)noBar for a value something else owns (0 to 1). Redraws itself when the value moves.
APISubmenuComponent(text, IMenu or factory)yesOpens another menu.
APIChatColoredTextComponent(text, chatColor)yesText coloured with a Helper.ChatColors tag. On the centre-HTML renderer it maps to the closest hex colour.
APIDividerComponent(length, color)noA horizontal rule drawn from ─ characters.
APISpacerComponent(lines)noBlank 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:

PropertyDescription
Visible / EnabledShow or enable the component for every player.
VisibleWhen / EnabledWhenFunc<IMenuSession, bool> deciding it per player, evaluated every frame. When set, combined with Visible/Enabled.
CommentHint shown in the footer while the component holds the selection.
PlaySoundWhether activating the component plays the selection sound (default true).
ValidatingFunc<MenuActionContext, ValueTask<bool>> that runs before an activation; return false to cancel it.
WaitingText / WaitingColorWhat is drawn while an async activation is still running (default "Waiting..."). The same player cannot trigger it twice meanwhile.
TagFree-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 changed

OnChanged 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.

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:

ActionDefault keyEffect
MenuActions.NavigateUpShiftMoves the selection up (wraps around).
MenuActions.NavigateDownFMoves the selection down (wraps around).
MenuActions.NavigateLeftADoes nothing on its own. Sliders and selectors take it over while focused.
MenuActions.NavigateRightDDoes nothing on its own. Sliders and selectors take it over while focused.
MenuActions.SelectEActivates the focused component.
MenuActions.CloseTabCloses 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 when label is null).
  • 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:

PrioritySource
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 !0 to 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();
  • Register returns an IDisposable; disposing it removes the renderer. Registering an id that already exists replaces the old renderer.
  • Registrations made through Core.Menu are released when your plugin unloads.
  • Use a plugin-namespaced id ("myplugin.plain") so it never clashes with another plugin or with MenuRendererIds.
  • 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" in menus.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:

MemberDescription
FrameThe composed APIMenuFrame: Header, Body and Footer node lists.
Menu, SessionThe menu and the player's session being drawn.
PlayerThe 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.

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.

RegionFrame propertyBehaviour
HeaderHeaderPinned above the body. Never scrolls, never takes focus. Holds the title by default.
BodyBodyThe scrolling part. Already reduced to the visible page. Focusable entries carry a MenuSelectionNode marker slot.
FooterFooterPinned 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. MaxVisibleLines and WithItemsPerPage limit 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 / VisibleWhen are 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

NodeMeaning
APIMenuTextNodeA run of text with a APIMenuTextStyle (Color as hex, Bold, Size).
APIMenuLineNodeChildren laid out horizontally on one line.
APIMenuStackNodeChildren stacked vertically, one per line.
APIMenuBlankNodeLines of empty vertical space.
APIMenuSelectionNodeThe 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.
APIMenuRawNodePayload 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 (ExtraSmall to ExtraLarge) and colours onto whatever your output supports, or ignore them.
  • Pair your renderer with an input method: menus drawn by the chat renderer default to MenuInputMethod.Chat, every other renderer defaults to Buttons. If your output shows !1, !2, ... numbers, call WithInputMethod(MenuInputMethod.Chat) on the menu, or add a WithInputMethodSource.
  • 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 false from TryRender to fall back to the component's own Render.
  • RendererId can 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, IsEnabled and RendererId.
  • 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.

APIIMenu is the shared definition; APIIMenuSession is one player's live view of it.

APIIMenu memberDescription
Id, Renderer, Keymap, InputMethod, ItemsPerPage, AutoCloseDelayWhat the menu was built with (resolved after server-side overrides).
ParentThe menu to return to on close. Writable, so lazily built submenus can be told.
TagFree-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, RemoveReads and edits components, see Dynamic Components.
Opened, Closed, FocusChangedevent Action<IMenuSession>.

IMenu is IDisposable; disposing closes it for everyone and clears its components.

APIIMenuSession memberDescription
Player, Menu, IsOpenWho is viewing what, and whether it is still open.
FocusedIndex, FocusedComponentThe 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) / SetStatePer-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 true from HandleActionAsync to consume the action; return false to let the menu navigate or close as usual.
  • ActivateAsync runs your work through the shared pipeline: it honours Enabled, Validating and the busy/waiting state.
  • Override LineCount when Render returns more than one line (a MenuStackNode costs the sum of its children, a MenuBlankNode its Lines). Otherwise the page is budgeted for one line and overflows.
  • Override GetHint to show a hint while focused, and NeedsRedraw(session, now) for animated or live content (asked once per tick for every visible component, so keep it cheap).
  • Return a MenuLineNode to put several styled parts on one line, and a MenuRawNode to 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 }
    }
}
KeyDescription
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 / InputMethodForces 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.MaxVisibleLinesCap 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 LineCount on custom multi-line components.
  • Keep NeedsRedraw and TextProvider cheap; they run every tick/frame.