Menus
SwiftlyS2 menus provide an interactive, per-player UI layer for settings, selections, and action flows, through Core.MenusAPI (APIIMenuManagerAPI).
Recommended Workflow
- Create a builder with
Core.MenusAPI.CreateBuilder(). - Configure behavior (sound, freeze, auto-close, keybind overrides) and appearance (
builder.Design). - Add options and call
Build(). - Open and close through
Core.MenusAPI.
Builder Configuration
Every call under .Design returns back to the builder, so chain .Design. again for each design call:
var menu = Core.MenusAPI.CreateBuilder()
.EnableSound()
.SetPlayerFrozen(false)
.SetAutoCloseDelay(0f)
.SetSelectButton(KeyBind.E | KeyBind.Mouse1) // KeyBind is a [Flags] enum
.SetMoveForwardButton(KeyBind.W)
.SetMoveBackwardButton(KeyBind.S)
.SetExitButton(KeyBind.Esc)
.AddExtraButton(KeyBind.R, "Reset", (p, m) =>
{
p.SendChat("Reset action executed.");
})
.Design.SetMenuTitle("Gameplay Settings")
.Design.SetMenuTitleItemCountVisible(true)
.Design.SetMenuFooterVisible(true)
.Design.SetCommentVisible(true)
.Design.SetDefaultComment("Use W/S to move and E to select")
.Design.SetMaxVisibleItems(5)
.Design.SetGlobalScrollStyle(MenuOptionScrollStyle.WaitingCenter)
.Build();
Core.MenusAPI.OpenMenuForPlayer(player, menu);MaxVisibleItems only accepts [1, 5] or -1 (falls back to
ItemsPerPage in configs/core.jsonc, see Core
Configuration) - out-of-range values log an
error and reset to -1.
Option Types
Built-in options live under SwiftlyS2.Core.Menus.OptionsBase:
- APITextMenuOption: non-interactive informational line.
- APIButtonMenuOption: clickable action.
- APIToggleMenuOption: per-player on/off value.
- APISliderMenuOption: numeric range with step.
- APIChoiceMenuOption: per-player value from a string list.
- APIInputMenuOption: chat input with validation.
- APIProgressBarMenuOption: dynamic progress display.
- APISubmenuMenuOption: opens another menu.
- APISelectorMenuOption`1: typed selector with previous/next behavior.
var slider = new SliderMenuOption(
text: "Round Time",
min: 60f,
max: 300f,
defaultValue: 120f,
step: 30f,
totalBars: 8
);
slider.ValueChanged += (sender, args) =>
{
args.Player.SendChat($"Round time: {args.NewValue:0}s");
};
var menu = Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Player Preferences")
.AddOption(slider)
.Build();The other value-holding options (APIToggleMenuOption, APIInputMenuOption, APIChoiceMenuOption, APISelectorMenuOption`1) follow the same constructor-plus-ValueChanged-handler pattern, just with their own value type (bool, string, etc. - see APIMenuOptionValueChangedEventArgs`1.OldValue/APIMenuOptionValueChangedEventArgs`1.NewValue).
Submenus
Provide a pre-built submenu, or build it lazily when selected.
var advancedMenu = Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Advanced")
.AddOption(new ButtonMenuOption("Do advanced action"))
.Build();
var openAdvanced = new SubmenuMenuOption("Advanced", advancedMenu);
var lazyAdvanced = new SubmenuMenuOption("Lazy Advanced", () =>
{
return Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Loaded On Demand")
.AddOption(new TextMenuOption("This submenu was built on click"))
.Build();
});Open and Close Menus
Core.MenusAPI.OpenMenuForPlayer(player, menu);
Core.MenusAPI.CloseActiveMenu(player);
Core.MenusAPI.OpenMenu(menu);
Core.MenusAPI.CloseMenu(menu);
Core.MenusAPI.CloseAllMenus();
var current = Core.MenusAPI.GetCurrentMenu(player);APIIMenuAPI.ShowForPlayer/ APIIMenuAPI.HideForPlayer only affect visual display - prefer the manager methods above for full state handling and events.
Events
Global manager events
public override void Load(bool hotReload)
{
Core.MenusAPI.MenuOpened += OnMenuOpened;
Core.MenusAPI.MenuClosed += OnMenuClosed;
}
public override void Unload()
{
Core.MenusAPI.MenuOpened -= OnMenuOpened;
Core.MenusAPI.MenuClosed -= OnMenuClosed;
}
private void OnMenuOpened(object? sender, MenuManagerEventArgs args)
{
if (args.Player != null)
Core.Logger.LogInformation("Menu opened for {SteamId}", args.Player.SteamID);
}
private void OnMenuClosed(object? sender, MenuManagerEventArgs args) { /* same shape */ }Both handlers receive APIMenuManagerEventArgs.
Per-menu and per-option events
var adminOnly = new ButtonMenuOption("Admin Action");
adminOnly.Validating += (sender, args) =>
{
// args.Player, args.Option are also available here
if (!Core.Permission.PlayerHasPermission(args.Player.SteamID, "admin"))
args.Cancel = true;
};
adminOnly.Click += (sender, args) =>
{
args.Player.SendChat("Admin action executed.");
return ValueTask.CompletedTask;
};
adminOnly.BeforeFormat += (sender, args) => args.CustomText = $"[SECURE] {args.Option.Text}";
adminOnly.AfterFormat += (sender, args) => args.CustomText = $"<font color='#FFD700'>{args.CustomText}</font>";
var menu = Core.MenusAPI.CreateBuilder().Design.SetMenuTitle("Admin").AddOption(adminOnly).Build();
// menu.OptionHovering: fired every render frame while hovering - keep this cheap.
// menu.OptionHovered: fired only when the hovered option changes.
// menu.OptionSelected: fired when a selection is activated.APIMenuOptionValidatingEventArgs only exposes Player,
Option, and a settable Cancel bool - there is no CancelReason. Send a
chat message from the Validating handler yourself before setting Cancel = true if the player needs to know why.
Runtime Updates
var option = new ButtonMenuOption("Dynamic Option");
var menu = Core.MenusAPI.CreateBuilder().AddOption(option).Build();
menu.AddOption(new TextMenuOption("Added later"));
menu.MoveToOptionIndex(player, 0);
option.SetVisible(player, false);
option.SetEnabled(player, false);APIIMenuOption.Visible/
APIIMenuOption.Enabled are the global states;
SetVisible/SetEnabled(player, ...) are per-player overrides on top of them.
Direct CreateMenu Usage
APIIMenuManagerAPI.CreateMenu is a lower-level
alternative to the builder, taking explicit APIMenuConfiguration/
APIMenuKeybindOverrides objects (the same settings the builder's
.Design/Set*Button calls configure) instead of a fluent chain:
var menu = Core.MenusAPI.CreateMenu(
new MenuConfiguration { Title = "Raw Menu", MaxVisibleItems = 5 },
new MenuKeybindOverrides { Select = KeyBind.E, Move = KeyBind.W, MoveBack = KeyBind.S, Exit = KeyBind.Esc },
parent: null,
optionScrollStyle: MenuOptionScrollStyle.CenterFixed,
optionTextStyle: MenuOptionTextStyle.TruncateEnd
);Common Pitfalls
- Open and close menus through
Core.MenusAPImanager methods, not onlyShowForPlayer/HideForPlayer. - Keep heavy logic out of
OptionHovering- it runs every render frame while hovering. - Unsubscribe manager-level events (
MenuOpened/MenuClosed) during pluginUnload().